test-drive-skill 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 deyna256
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,21 @@
1
+ Metadata-Version: 2.4
2
+ Name: test-drive-skill
3
+ Version: 0.1.0
4
+ Summary: Helpers for the Test Drive agent skill
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.10
8
+ Project-URL: Repository, https://github.com/deyna256/test-drive
9
+ Project-URL: Issues, https://github.com/deyna256/test-drive/issues
10
+ Description-Content-Type: text/markdown
11
+
12
+ # test-drive-skill
13
+
14
+ The Python helpers for [Test Drive](https://github.com/deyna256/test-drive), an agent skill for
15
+ testing web user journeys and creating reports with recordings. The `test-drive` command checks
16
+ scenario file structure, selects or sets up `agent-browser`, and renders HTML reports.
17
+
18
+ The package requires Python 3.10 or newer and has no third-party Python runtime dependencies.
19
+ Browser testing also requires `agent-browser` and Chrome or Chromium. Recording WebM video requires
20
+ FFmpeg with the `libvpx` encoder. Install the agent skill separately; see the
21
+ [project README](https://github.com/deyna256/test-drive#readme) for setup and usage.
@@ -0,0 +1,10 @@
1
+ # test-drive-skill
2
+
3
+ The Python helpers for [Test Drive](https://github.com/deyna256/test-drive), an agent skill for
4
+ testing web user journeys and creating reports with recordings. The `test-drive` command checks
5
+ scenario file structure, selects or sets up `agent-browser`, and renders HTML reports.
6
+
7
+ The package requires Python 3.10 or newer and has no third-party Python runtime dependencies.
8
+ Browser testing also requires `agent-browser` and Chrome or Chromium. Recording WebM video requires
9
+ FFmpeg with the `libvpx` encoder. Install the agent skill separately; see the
10
+ [project README](https://github.com/deyna256/test-drive#readme) for setup and usage.
@@ -0,0 +1,51 @@
1
+ [build-system]
2
+ requires = ["uv_build>=0.13.0,<0.14.0"]
3
+ build-backend = "uv_build"
4
+
5
+ [project]
6
+ name = "test-drive-skill"
7
+ version = "0.1.0"
8
+ description = "Helpers for the Test Drive agent skill"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ dependencies = []
14
+
15
+ [project.urls]
16
+ Repository = "https://github.com/deyna256/test-drive"
17
+ Issues = "https://github.com/deyna256/test-drive/issues"
18
+
19
+ [project.scripts]
20
+ test-drive = "test_drive.cli:main"
21
+
22
+ [dependency-groups]
23
+ dev = [
24
+ "ruff>=0.16.10",
25
+ "ty>=0.0.84",
26
+ ]
27
+
28
+ [tool.uv.build-backend]
29
+ module-name = "test_drive"
30
+
31
+ [tool.ruff]
32
+ line-length = 100
33
+ target-version = "py310"
34
+
35
+ [tool.ruff.lint]
36
+ select = [
37
+ "E",
38
+ "W",
39
+ "F",
40
+ "I",
41
+ "B",
42
+ "C4",
43
+ "UP",
44
+ "SIM",
45
+ "RUF",
46
+ ]
47
+ ignore = ["E501"]
48
+
49
+ [tool.ruff.lint.per-file-ignores]
50
+ "**/test_drive/report.py" = ["RUF001"]
51
+ "**/tests/**" = ["RUF001"]
@@ -0,0 +1,40 @@
1
+ [build-system]
2
+ requires = ["uv_build>=0.13.0,<0.14.0"]
3
+ build-backend = "uv_build"
4
+
5
+ [project]
6
+ name = "test-drive-skill"
7
+ version = "0.1.0"
8
+ description = "Helpers for the Test Drive agent skill"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ dependencies = []
14
+
15
+ [project.urls]
16
+ Repository = "https://github.com/deyna256/test-drive"
17
+ Issues = "https://github.com/deyna256/test-drive/issues"
18
+
19
+ [project.scripts]
20
+ test-drive = "test_drive.cli:main"
21
+
22
+ [dependency-groups]
23
+ dev = ["ruff>=0.16.10", "ty>=0.0.84"]
24
+
25
+ [tool.uv.build-backend]
26
+ module-name = "test_drive"
27
+
28
+ [tool.ruff]
29
+ line-length = 100
30
+ target-version = "py310"
31
+
32
+ [tool.ruff.lint]
33
+ select = ["E", "W", "F", "I", "B", "C4", "UP", "SIM", "RUF"]
34
+ # The formatter cannot wrap every HTML, URL, or multilingual fixture string.
35
+ ignore = ["E501"]
36
+
37
+ [tool.ruff.lint.per-file-ignores]
38
+ # Russian labels and multilingual scenarios legitimately contain non-Latin characters.
39
+ "**/test_drive/report.py" = ["RUF001"]
40
+ "**/tests/**" = ["RUF001"]
@@ -0,0 +1 @@
1
+ """Runtime helpers for the Test Drive agent skill."""
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,69 @@
1
+ """Find the browser CLI version used by Test Drive."""
2
+
3
+ import argparse
4
+ import os
5
+ import re
6
+ import shutil
7
+ import subprocess
8
+ import sys
9
+ from pathlib import Path
10
+
11
+
12
+ def runtime_directory() -> Path:
13
+ return Path(
14
+ os.environ.get("TEST_DRIVE_RUNTIME_DIR") or Path.home() / ".test-drive" / "agent-browser"
15
+ ).expanduser()
16
+
17
+
18
+ def runtime_browser(directory: Path) -> Path:
19
+ return (
20
+ directory
21
+ / "node_modules"
22
+ / ".bin"
23
+ / ("agent-browser.cmd" if os.name == "nt" else "agent-browser")
24
+ )
25
+
26
+
27
+ def main() -> int:
28
+ argparse.ArgumentParser(description="Find a compatible agent-browser CLI").parse_args()
29
+ managed = runtime_browser(runtime_directory())
30
+ browser = os.environ.get("TEST_DRIVE_AGENT_BROWSER") or (
31
+ str(managed) if managed.is_file() else shutil.which("agent-browser")
32
+ )
33
+ if not browser:
34
+ print(
35
+ "agent-browser >=0.40.0 was not found; set TEST_DRIVE_AGENT_BROWSER to its executable path.",
36
+ file=sys.stderr,
37
+ )
38
+ return 1
39
+
40
+ try:
41
+ path = Path(browser).expanduser().resolve(strict=True)
42
+ result = subprocess.run(
43
+ [str(path), "--version"],
44
+ capture_output=True,
45
+ text=True,
46
+ errors="replace",
47
+ timeout=10,
48
+ check=False,
49
+ )
50
+ except (OSError, RuntimeError, subprocess.TimeoutExpired) as exc:
51
+ print(f"Cannot run agent-browser at {browser}: {exc}", file=sys.stderr)
52
+ return 1
53
+
54
+ version = result.stdout.strip()
55
+ match = re.fullmatch(
56
+ r"agent-browser (0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:\+[0-9A-Za-z.-]+)?", version
57
+ )
58
+ if result.returncode != 0 or not match or tuple(map(int, match.groups())) < (0, 40, 0):
59
+ print(
60
+ f"Test Drive requires a stable agent-browser >=0.40.0; {path} reported {version or 'no version'}.",
61
+ file=sys.stderr,
62
+ )
63
+ return 1
64
+ print(path)
65
+ return 0
66
+
67
+
68
+ if __name__ == "__main__":
69
+ sys.exit(main())
@@ -0,0 +1,127 @@
1
+ """Set up the browser CLI without changing a global installation."""
2
+
3
+ import argparse
4
+ import os
5
+ import re
6
+ import shutil
7
+ import subprocess
8
+ import sys
9
+ import tempfile
10
+ from pathlib import Path
11
+
12
+ from .agent_browser import runtime_browser, runtime_directory
13
+
14
+
15
+ def main() -> int:
16
+ parser = argparse.ArgumentParser(description="Set up Test Drive's browser CLI")
17
+ parser.add_argument("--directory", type=Path, default=runtime_directory())
18
+ args = parser.parse_args()
19
+ existing = subprocess.run(
20
+ [sys.executable, str(Path(__file__).with_name("agent_browser.py"))],
21
+ capture_output=True,
22
+ text=True,
23
+ check=False,
24
+ )
25
+ if existing.returncode == 0:
26
+ print(f"Using agent-browser: {existing.stdout.strip()}")
27
+ return 0
28
+ if os.environ.get("TEST_DRIVE_AGENT_BROWSER"):
29
+ print(existing.stderr.strip(), file=sys.stderr)
30
+ return 1
31
+
32
+ target = args.directory.expanduser().resolve()
33
+ if target.exists():
34
+ if runtime_browser(target).is_file():
35
+ env = os.environ.copy()
36
+ env["TEST_DRIVE_AGENT_BROWSER"] = str(runtime_browser(target))
37
+ found = subprocess.run(
38
+ [sys.executable, str(Path(__file__).with_name("agent_browser.py"))],
39
+ env=env,
40
+ capture_output=True,
41
+ text=True,
42
+ check=False,
43
+ )
44
+ if found.returncode == 0:
45
+ print(f"Using agent-browser: {found.stdout.strip()}")
46
+ return 0
47
+ print(
48
+ f"Runtime directory already exists: {target}. Choose another directory; it was not changed.",
49
+ file=sys.stderr,
50
+ )
51
+ return 1
52
+ npm, node = shutil.which("npm"), shutil.which("node")
53
+ if not npm or not node:
54
+ print("Node.js 24+ and npm are required to install agent-browser.", file=sys.stderr)
55
+ return 1
56
+ npm_command = [npm]
57
+ if os.name == "nt" and Path(npm).suffix.lower() in (".cmd", ".bat"):
58
+ npm_cli = Path(npm).parent / "node_modules" / "npm" / "bin" / "npm-cli.js"
59
+ if not npm_cli.is_file():
60
+ print("Could not locate npm-cli.js for a safe local installation.", file=sys.stderr)
61
+ return 1
62
+ npm_command = [node, str(npm_cli)]
63
+ try:
64
+ node_version = subprocess.run(
65
+ [node, "--version"], capture_output=True, text=True, timeout=10, check=False
66
+ )
67
+ match = re.fullmatch(r"v(\d+)\.\d+\.\d+", node_version.stdout.strip())
68
+ if node_version.returncode or not match or int(match.group(1)) < 24:
69
+ print("Node.js 24+ is required to install agent-browser.", file=sys.stderr)
70
+ return 1
71
+ target.parent.mkdir(parents=True, exist_ok=True)
72
+ with tempfile.TemporaryDirectory(prefix=".test-drive-", dir=target.parent) as directory:
73
+ stage = Path(directory)
74
+ installed = subprocess.run(
75
+ [
76
+ *npm_command,
77
+ "install",
78
+ "--prefix",
79
+ str(stage),
80
+ "--no-global",
81
+ "--no-save",
82
+ "--no-package-lock",
83
+ "--ignore-scripts",
84
+ "agent-browser@0.40.0",
85
+ ],
86
+ cwd=stage,
87
+ capture_output=True,
88
+ text=True,
89
+ timeout=300,
90
+ check=False,
91
+ )
92
+ if installed.returncode:
93
+ error = re.search(
94
+ r"(?m)^npm (?:error|ERR!) code ([A-Z][A-Z0-9_]+)\s*$", installed.stderr
95
+ )
96
+ code = f", {error.group(1)}" if error else ""
97
+ print(
98
+ f"npm installation failed (exit {installed.returncode}{code}); check npm and network settings.",
99
+ file=sys.stderr,
100
+ )
101
+ return 1
102
+ env = os.environ.copy()
103
+ env["TEST_DRIVE_AGENT_BROWSER"] = str(runtime_browser(stage))
104
+ verified = subprocess.run(
105
+ [sys.executable, str(Path(__file__).with_name("agent_browser.py"))],
106
+ env=env,
107
+ capture_output=True,
108
+ text=True,
109
+ timeout=15,
110
+ check=False,
111
+ )
112
+ if verified.returncode:
113
+ print(
114
+ "The installed agent-browser could not be verified for this platform.",
115
+ file=sys.stderr,
116
+ )
117
+ return 1
118
+ stage.rename(target)
119
+ except (OSError, subprocess.TimeoutExpired) as exc:
120
+ print(f"Could not set up agent-browser: {exc}", file=sys.stderr)
121
+ return 1
122
+ print(f"Installed agent-browser: {runtime_browser(target)}")
123
+ return 0
124
+
125
+
126
+ if __name__ == "__main__":
127
+ sys.exit(main())
@@ -0,0 +1,141 @@
1
+ <!doctype html>
2
+ <html lang="$language" dir="$direction">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>$page_title — Test Drive</title>
7
+ <style>
8
+ :root { color-scheme: light; --ink:#18263a; --muted:#53667e; --line:#dce4ef; --accent:#295ec1; --paper:#fff; --background:#f3f6fa; }
9
+ * { box-sizing:border-box; }
10
+ body { margin:0; background:var(--background); color:var(--ink); font:15px/1.5 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; }
11
+ a { color:var(--accent); text-decoration-thickness:1px; text-underline-offset:3px; }
12
+ button { font:inherit; }
13
+ button:focus-visible, a:focus-visible, video:focus-visible { outline:3px solid #5c8cfc; outline-offset:3px; }
14
+ .topbar { background:#172a44; color:#fff; padding:14px clamp(18px,4vw,56px); display:flex; align-items:center; gap:11px; }
15
+ .mark { display:grid; place-items:center; width:32px; height:32px; border-radius:9px; background:#295ec1; font-size:13px; font-weight:800; letter-spacing:.02em; }
16
+ .brand { font-weight:750; letter-spacing:.01em; }
17
+ .brand-note { margin-inline-start:auto; color:#bdcbe0; font-size:12px; }
18
+ .shell { max-width:1500px; margin:auto; padding:30px clamp(18px,4vw,56px) 48px; }
19
+ .eyebrow { color:#46678d; font-size:11px; font-weight:800; letter-spacing:.12em; text-transform:uppercase; margin:0 0 6px; }
20
+ h1 { font-size:clamp(25px,3vw,34px); letter-spacing:-.03em; line-height:1.15; margin:0 0 8px; }
21
+ .metadata { display:flex; gap:8px 18px; flex-wrap:wrap; color:var(--muted); font-size:13px; overflow-wrap:anywhere; }
22
+ .context { margin-top:15px; color:var(--muted); font-size:13px; }
23
+ .context p { margin:5px 0; white-space:pre-wrap; overflow-wrap:anywhere; }
24
+ .context strong { color:var(--ink); }
25
+ .summary { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; margin:24px 0; }
26
+ .stat { background:var(--paper); border:1px solid var(--line); border-radius:10px; padding:12px 14px; min-width:0; }
27
+ .stat-number { font-size:23px; font-weight:750; line-height:1; font-variant-numeric:tabular-nums; }
28
+ .stat-label { color:var(--muted); font-size:12px; margin-top:5px; }
29
+ .workspace { display:grid; grid-template-columns:minmax(215px,275px) minmax(0,1fr); align-items:start; gap:16px; }
30
+ .sidebar, .panel { background:var(--paper); border:1px solid var(--line); border-radius:12px; }
31
+ .sidebar { position:sticky; top:16px; overflow:hidden; }
32
+ .sidebar-heading { padding:15px 16px 12px; border-bottom:1px solid var(--line); display:flex; justify-content:space-between; font-weight:750; }
33
+ .sidebar-heading span { color:var(--muted); font-weight:500; }
34
+ .scenario-nav { display:flex; flex-direction:column; gap:3px; padding:7px; max-height:calc(100vh - 180px); overflow:auto; }
35
+ .scenario-link { display:grid; grid-template-columns:27px minmax(0,1fr); gap:8px; padding:10px 9px; color:var(--ink); text-decoration:none; border-radius:8px; min-height:52px; }
36
+ .scenario-link:hover { background:#f2f6fd; }
37
+ .scenario-link[aria-current="true"] { background:#eaf1ff; color:#1e4b9e; }
38
+ .scenario-number { font:12px/1.5 ui-monospace,SFMono-Regular,Consolas,monospace; color:#52657e; padding-top:1px; }
39
+ .scenario-name { font-weight:700; overflow-wrap:anywhere; }
40
+ .scenario-status { display:block; font-size:12px; font-weight:500; margin-top:2px; }
41
+ .passed { color:#207248; } .failed { color:#a6322d; } .blocked { color:#8a5e12; } .not_run { color:#5f6c7d; }
42
+ .panel { padding:clamp(18px,2.6vw,30px); min-width:0; }
43
+ .panel[hidden] { display:none; }
44
+ .panel-heading { display:flex; justify-content:space-between; gap:12px; align-items:flex-start; }
45
+ h2 { font-size:clamp(20px,2vw,26px); line-height:1.25; letter-spacing:-.02em; margin:0 0 5px; overflow-wrap:anywhere; }
46
+ h3 { font-size:15px; margin:0 0 10px; }
47
+ .source { color:var(--muted); font-size:12px; margin:0; overflow-wrap:anywhere; }
48
+ .badge { padding:5px 9px; border-radius:99px; font-size:12px; font-weight:750; white-space:nowrap; }
49
+ .badge.passed { background:#eaf7ee; } .badge.failed { background:#fff0ee; } .badge.blocked { background:#fff5e2; } .badge.not_run { background:#eff2f6; }
50
+ .outcomes { display:grid; grid-template-columns:1fr 1fr; gap:10px; margin:22px 0; }
51
+ .outcome { background:#f5f8fc; border:1px solid #edf1f6; border-radius:9px; padding:13px 14px; min-width:0; }
52
+ .outcome-label { font-size:11px; font-weight:750; color:#526985; letter-spacing:.035em; margin-bottom:5px; }
53
+ .outcome p, .reason, .step-detail { margin:0; white-space:pre-wrap; overflow-wrap:anywhere; }
54
+ .reason { padding:11px 13px; margin:16px 0 18px; border-inline-start:3px solid #d9a344; background:#fffbf3; font-size:13px; }
55
+ .section { margin-top:24px; }
56
+ .criteria { list-style:none; margin:0; padding:0; display:grid; gap:10px; }
57
+ .criterion { padding:13px 14px; border:1px solid var(--line); border-radius:9px; background:#f9fbfd; }
58
+ .criterion-heading { display:flex; justify-content:space-between; align-items:flex-start; gap:12px; font-weight:700; }
59
+ .criterion-heading > span:first-child { white-space:pre-wrap; overflow-wrap:anywhere; }
60
+ .criterion-detail { margin:8px 0 0; white-space:pre-wrap; overflow-wrap:anywhere; color:var(--muted); font-size:13px; }
61
+ .criterion-detail strong { color:var(--ink); }
62
+ .criterion-evidence { display:flex; align-items:center; gap:12px; font-size:13px; }
63
+ .criterion-evidence:not(:empty) { margin-top:8px; }
64
+ .video-wrap { background:#101925; border-radius:9px; overflow:hidden; }
65
+ video { display:block; width:100%; max-height:560px; background:#101925; }
66
+ .asset-link { display:inline-block; font-size:13px; margin-top:8px; }
67
+ .missing { background:#f4f6f9; color:#66778b; border:1px dashed #cbd5e2; border-radius:9px; padding:16px; font-size:13px; }
68
+ .steps { list-style:none; margin:0; padding:0; padding-inline-start:10px; border-inline-start:2px solid #e3e9f2; }
69
+ .step { position:relative; padding:0 0 16px; padding-inline-start:16px; }
70
+ .step:last-child { padding-bottom:0; }
71
+ .step:before { content:""; position:absolute; inset-inline-start:-6px; top:7px; width:10px; height:10px; background:#fff; border:2px solid #7898c5; border-radius:50%; }
72
+ .step-title { display:flex; gap:8px; align-items:baseline; flex-wrap:wrap; font-weight:650; }
73
+ .seek { border:0; padding:6px 8px; min-width:48px; min-height:36px; background:#edf3ff; color:#2359b6; border-radius:5px; font:12px/1.5 ui-monospace,SFMono-Regular,Consolas,monospace; cursor:pointer; }
74
+ .seek:hover { background:#dce9ff; }
75
+ .seek:disabled { color:#7d8794; background:#eff1f4; cursor:not-allowed; }
76
+ .step-detail { color:var(--muted); font-size:13px; margin-top:4px; }
77
+ .step-status { font-size:12px; font-weight:600; }
78
+ .evidence { display:block; max-width:100%; max-height:460px; border:1px solid var(--line); border-radius:9px; }
79
+ details { margin-top:18px; border-top:1px solid var(--line); padding-top:14px; }
80
+ summary { cursor:pointer; color:#375b91; font-weight:650; }
81
+ details ol { padding-left:22px; }
82
+ details li { padding:3px 0; white-space:pre-wrap; overflow-wrap:anywhere; }
83
+ .empty { background:var(--paper); border:1px solid var(--line); border-radius:12px; padding:25px; color:var(--muted); }
84
+ .footnote { color:var(--muted); font-size:12px; margin:24px 0 0; }
85
+ @media (max-width:760px) { .summary { grid-template-columns:repeat(2,minmax(0,1fr)); } .workspace { grid-template-columns:1fr; } .sidebar { position:static; } .scenario-nav { max-height:none; } .brand-note { display:none; } .seek { min-width:48px; min-height:44px; } }
86
+ @media (max-width:480px) { .outcomes { grid-template-columns:1fr; } .panel-heading { flex-direction:column; } }
87
+ </style>
88
+ </head>
89
+ <body data-seek-outside="$seek_outside">
90
+ <header class="topbar"><span class="mark" aria-hidden="true">TD</span><span class="brand">Test Drive</span><span class="brand-note">$brand_note</span></header>
91
+ <main class="shell">
92
+ <p class="eyebrow">$report_label</p>
93
+ <h1>$site_name</h1>
94
+ <div class="metadata">$metadata</div>
95
+ $context
96
+ <section class="summary" aria-label="$summary_aria">$summary</section>
97
+ $content
98
+ <p class="footnote">$footnote</p>
99
+ </main>
100
+ <script>
101
+ const panels = Array.from(document.querySelectorAll('.panel[id]'));
102
+ const links = Array.from(document.querySelectorAll('.scenario-link'));
103
+ function showScenario(id) {
104
+ if (!panels.some(panel => panel.id === id)) return;
105
+ panels.forEach(panel => {
106
+ if (panel.id !== id) panel.querySelectorAll('video').forEach(video => video.pause());
107
+ panel.hidden = panel.id !== id;
108
+ });
109
+ links.forEach(link => {
110
+ if (link.hash === '#' + id) link.setAttribute('aria-current', 'true');
111
+ else link.removeAttribute('aria-current');
112
+ });
113
+ }
114
+ if (panels.length) {
115
+ const initial = document.querySelector('.scenario-link[data-default]');
116
+ const navigate = () => {
117
+ const id = location.hash.slice(1);
118
+ showScenario(panels.some(panel => panel.id === id) ? id : initial.hash.slice(1));
119
+ };
120
+ navigate();
121
+ window.addEventListener('hashchange', navigate);
122
+ }
123
+ document.querySelectorAll('.seek').forEach(button => button.addEventListener('click', () => {
124
+ const video = button.closest('.panel').querySelector('video');
125
+ const time = Number(button.dataset.time);
126
+ if (!video) return;
127
+ const seek = () => {
128
+ if (Number.isFinite(video.duration) && time > video.duration) {
129
+ button.disabled = true;
130
+ button.title = document.body.dataset.seekOutside;
131
+ return;
132
+ }
133
+ video.currentTime = time;
134
+ video.play().catch(() => {});
135
+ };
136
+ if (video.readyState < 1) video.addEventListener('loadedmetadata', seek, { once:true });
137
+ else seek();
138
+ }));
139
+ </script>
140
+ </body>
141
+ </html>
@@ -0,0 +1,31 @@
1
+ """Command-line entry point for the skill's runtime helpers."""
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+ from importlib import metadata
7
+
8
+ from . import agent_browser, agent_browser_setup, report, scenarios
9
+
10
+
11
+ def main() -> int:
12
+ parser = argparse.ArgumentParser(prog="test-drive", description="Test Drive skill helpers")
13
+ parser.add_argument(
14
+ "--version",
15
+ action="version",
16
+ version=f"test-drive-skill {metadata.version('test-drive-skill')}",
17
+ )
18
+ commands = {
19
+ "agent-browser-path": agent_browser.main,
20
+ "setup-agent-browser": agent_browser_setup.main,
21
+ "check-scenarios": scenarios.main,
22
+ "render-report": report.main,
23
+ }
24
+ parser.add_argument("command", choices=(*commands, "report-labels"))
25
+ args = parser.parse_args(sys.argv[1:2])
26
+ if args.command == "report-labels":
27
+ argparse.ArgumentParser(prog="test-drive report-labels").parse_args(sys.argv[2:])
28
+ print(json.dumps(report.LABELS, ensure_ascii=False, indent=2))
29
+ return 0
30
+ sys.argv = [f"test-drive {args.command}", *sys.argv[2:]]
31
+ return commands[args.command]()
@@ -0,0 +1,549 @@
1
+ """Build a portable HTML report from a run's JSON and local evidence files."""
2
+
3
+ import argparse
4
+ import html
5
+ import json
6
+ import math
7
+ import re
8
+ import subprocess
9
+ import sys
10
+ import tempfile
11
+ from collections import Counter
12
+ from pathlib import Path
13
+ from string import Template
14
+ from urllib.parse import quote, urlsplit
15
+
16
+ STATUSES = {
17
+ "passed": ("Passed", "Passed"),
18
+ "failed": ("Failed", "Failed"),
19
+ "blocked": ("Blocked", "Blocked"),
20
+ "not_run": ("Not run", "Not run"),
21
+ }
22
+ LABELS = {
23
+ "brand_note": "Test report",
24
+ "report": "Test run",
25
+ "summary": "Run summary",
26
+ "scenarios": "Scenarios",
27
+ "video": "Recording",
28
+ "video_label": "Scenario recording",
29
+ "video_unsupported": "Your browser does not support video.",
30
+ "video_open": "Open recording",
31
+ "video_missing": "Recording unavailable",
32
+ "video_invalid": "The recording could not be decoded",
33
+ "video_unverified": "The recording could not be verified with ffmpeg",
34
+ "expected": "Expected",
35
+ "actual": "Actual",
36
+ "not_specified": "Not specified",
37
+ "not_recorded": "Not recorded",
38
+ "criteria": "Acceptance criteria",
39
+ "observed": "Observed",
40
+ "reason": "Reason",
41
+ "evidence": "Screenshot",
42
+ "steps": "Steps",
43
+ "steps_missing": "Steps not recorded",
44
+ "screenshot": "Result screenshot",
45
+ "screenshot_alt": "Scenario result screenshot",
46
+ "screenshot_missing": "Screenshot unavailable",
47
+ "reproduction": "How to reproduce",
48
+ "scope": "Scope",
49
+ "assumptions": "Assumptions",
50
+ "limitations": "Limitations",
51
+ "empty": "No scenarios recorded. The test did not run.",
52
+ "footnote": "The report and available artifacts are stored in the same folder.",
53
+ "seek": "Go approximately to",
54
+ "seek_outside": "The timestamp is outside the recording",
55
+ }
56
+ for status, (single, plural) in STATUSES.items():
57
+ LABELS[status] = single
58
+ LABELS[f"{status}_count"] = plural
59
+ # Older runs could specify Russian without storing translated labels.
60
+ LEGACY_RU_LABELS = {
61
+ "brand_note": "Отчёт о проверке",
62
+ "report": "Отчёт о прогоне",
63
+ "summary": "Итоги прогона",
64
+ "scenarios": "Сценарии",
65
+ "video": "Запись прогона",
66
+ "video_label": "Запись сценария",
67
+ "video_unsupported": "Ваш браузер не поддерживает видео.",
68
+ "video_open": "Открыть запись",
69
+ "video_missing": "Запись недоступна",
70
+ "video_invalid": "Не удалось декодировать запись",
71
+ "video_unverified": "Не удалось проверить запись с помощью ffmpeg",
72
+ "expected": "Ожидалось",
73
+ "actual": "Получилось",
74
+ "not_specified": "Не указано",
75
+ "not_recorded": "Не зафиксировано",
76
+ "criteria": "Критерии приёмки",
77
+ "observed": "Наблюдалось",
78
+ "reason": "Причина",
79
+ "evidence": "Снимок",
80
+ "steps": "Шаги",
81
+ "steps_missing": "Шаги не записаны",
82
+ "screenshot": "Снимок результата",
83
+ "screenshot_alt": "Снимок результата сценария",
84
+ "screenshot_missing": "Снимок недоступен",
85
+ "reproduction": "Как воспроизвести",
86
+ "scope": "Что проверяли",
87
+ "assumptions": "Допущения",
88
+ "limitations": "Ограничения",
89
+ "empty": "Сценарии не записаны. Проверка не выполнена.",
90
+ "footnote": "Отчёт и доступные материалы хранятся в одной папке.",
91
+ "seek": "Перейти примерно к",
92
+ "seek_outside": "Время шага за пределами записи",
93
+ "passed": "Пройден",
94
+ "passed_count": "Пройдено",
95
+ "failed": "Ошибка",
96
+ "failed_count": "Ошибок",
97
+ "blocked": "Не удалось проверить",
98
+ "blocked_count": "Не удалось проверить",
99
+ "not_run": "Не запускался",
100
+ "not_run_count": "Не запускалось",
101
+ }
102
+ TEMPLATE = Path(__file__).resolve().parent / "assets" / "report.html"
103
+
104
+
105
+ def text(value: object) -> str:
106
+ return html.escape(str(value), quote=True)
107
+
108
+
109
+ def valid_time(value: object) -> bool:
110
+ try:
111
+ return (
112
+ not isinstance(value, bool)
113
+ and isinstance(value, (int, float))
114
+ and math.isfinite(value)
115
+ and value >= 0
116
+ )
117
+ except OverflowError:
118
+ return False
119
+
120
+
121
+ def asset_url(root: Path, value: object) -> str | None:
122
+ if not isinstance(value, str) or not value or Path(value).is_absolute():
123
+ return None
124
+ try:
125
+ file = (root / value).resolve()
126
+ if not file.is_relative_to(root) or not file.is_file() or file.stat().st_size == 0:
127
+ return None
128
+ return quote(file.relative_to(root).as_posix(), safe="/")
129
+ except (OSError, RuntimeError, ValueError):
130
+ return None
131
+
132
+
133
+ def recording_url(root: Path, value: object) -> tuple[str | None, str | None]:
134
+ url = asset_url(root, value)
135
+ if not url or not isinstance(value, str):
136
+ return None, None
137
+ try:
138
+ with (root / value).open("rb") as recording:
139
+ for protocol, address in (("fd", "fd:"), ("pipe", "pipe:0")):
140
+ recording.seek(0)
141
+ result = subprocess.run(
142
+ [
143
+ "ffmpeg",
144
+ "-v",
145
+ "error",
146
+ "-xerror",
147
+ "-nostdin",
148
+ "-protocol_whitelist",
149
+ protocol,
150
+ "-i",
151
+ address,
152
+ "-map",
153
+ "0:v:0",
154
+ "-progress",
155
+ "pipe:1",
156
+ "-f",
157
+ "null",
158
+ "-",
159
+ ],
160
+ stdin=recording,
161
+ capture_output=True,
162
+ text=True,
163
+ errors="replace",
164
+ timeout=30,
165
+ check=False,
166
+ )
167
+ if protocol != "fd" or "Protocol not found" not in result.stderr:
168
+ break
169
+ except (OSError, subprocess.TimeoutExpired):
170
+ return None, "video_unverified"
171
+ if result.returncode or not re.search(r"^frame=\s*[1-9]\d*\s*$", result.stdout, re.MULTILINE):
172
+ return None, "video_invalid"
173
+ return url, None
174
+
175
+
176
+ def validate_time(item: dict, started: int | float | None, number: int) -> None:
177
+ at = item.get("at")
178
+ recorded = item.get("recorded_at")
179
+ if (at is not None and not valid_time(at)) or (
180
+ recorded is not None and not valid_time(recorded)
181
+ ):
182
+ raise ValueError(f"Scenario {number} has an invalid step or criterion time")
183
+ if recorded is not None and (at is not None or started is None or recorded < started):
184
+ raise ValueError(f"Scenario {number} time does not match the recording start")
185
+
186
+
187
+ def criteria_status(criteria: list[dict]) -> str:
188
+ statuses = {item["status"] for item in criteria}
189
+ if "failed" in statuses:
190
+ return "failed"
191
+ if statuses == {"passed"}:
192
+ return "passed"
193
+ if statuses == {"not_run"}:
194
+ return "not_run"
195
+ return "blocked"
196
+
197
+
198
+ def validate(run: object) -> None:
199
+ if not isinstance(run, dict) or not isinstance(run.get("site"), dict):
200
+ raise ValueError("A site object with a name is required")
201
+ if not isinstance(run["site"].get("name"), str) or not run["site"]["name"].strip():
202
+ raise ValueError("site.name is required")
203
+ if run["site"].get("url") is not None and not isinstance(run["site"]["url"], str):
204
+ raise ValueError("site.url must be a string")
205
+ if run["site"].get("url"):
206
+ try:
207
+ url = urlsplit(run["site"]["url"])
208
+ except ValueError:
209
+ raise ValueError(
210
+ "site.url must be a valid address without credentials, query, or fragment"
211
+ ) from None
212
+ if url.username is not None or url.password is not None or url.query or url.fragment:
213
+ raise ValueError("site.url must omit credentials, query, and fragment")
214
+ values = [run]
215
+ while values:
216
+ value = values.pop()
217
+ if isinstance(value, dict):
218
+ values.extend(value.values())
219
+ elif isinstance(value, list):
220
+ values.extend(value)
221
+ elif isinstance(value, str):
222
+ for match in re.finditer(r"\b(?:https?|file)://[^\s<>\"`]+", value, re.IGNORECASE):
223
+ try:
224
+ url = urlsplit(match.group())
225
+ unsafe = (
226
+ url.username is not None
227
+ or url.password is not None
228
+ or url.query
229
+ or url.fragment
230
+ )
231
+ except ValueError:
232
+ unsafe = True
233
+ if unsafe:
234
+ raise ValueError("Saved URLs must omit credentials, query, and fragment")
235
+ for field in ("started_at", "duration", "scope", "assumptions", "limitations"):
236
+ if run.get(field) is not None and not isinstance(run[field], str):
237
+ raise ValueError(f"{field} must be a string")
238
+ language = run.get("language", "en")
239
+ if not isinstance(language, str) or not re.fullmatch(
240
+ r"[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*", language
241
+ ):
242
+ raise ValueError("language must be a language code, such as en or en-US")
243
+ if run.get("direction", "ltr") not in ("ltr", "rtl"):
244
+ raise ValueError("direction must be ltr or rtl")
245
+ labels = run.get("labels", {})
246
+ if not isinstance(labels, dict) or any(
247
+ key not in LABELS or not isinstance(value, str) or not value.strip()
248
+ for key, value in labels.items()
249
+ ):
250
+ raise ValueError("labels must contain known keys with non-empty strings")
251
+ if language.split("-", 1)[0].lower() not in ("en", "ru") and labels.keys() != LABELS.keys():
252
+ raise ValueError("Provide translations for all labels when using another language")
253
+ if not isinstance(run.get("scenarios"), list):
254
+ raise ValueError("A scenarios list is required")
255
+ for number, scenario in enumerate(run["scenarios"], 1):
256
+ if (
257
+ not isinstance(scenario, dict)
258
+ or not isinstance(scenario.get("name"), str)
259
+ or not scenario["name"].strip()
260
+ ):
261
+ raise ValueError(f"Scenario {number} has no name")
262
+ if not isinstance(scenario.get("status"), str) or scenario["status"] not in STATUSES:
263
+ raise ValueError(f"Scenario {number} has an unknown status")
264
+ for field in (
265
+ "source",
266
+ "expected",
267
+ "actual",
268
+ "reason",
269
+ "video",
270
+ "video_reason",
271
+ "screenshot",
272
+ ):
273
+ if scenario.get(field) is not None and not isinstance(scenario[field], str):
274
+ raise ValueError(f"Scenario {number} field {field} must be a string")
275
+ if not isinstance(scenario.get("steps", []), list) or not isinstance(
276
+ scenario.get("reproduction", []), list
277
+ ):
278
+ raise ValueError(f"Scenario {number} steps and reproduction must be lists")
279
+ if any(
280
+ not isinstance(item, str) or not item.strip()
281
+ for item in scenario.get("reproduction", [])
282
+ ):
283
+ raise ValueError(f"Scenario {number} reproduction steps must be non-empty strings")
284
+ started = scenario.get("video_started_at")
285
+ if started is not None and not valid_time(started):
286
+ raise ValueError(f"Scenario {number} has an invalid recording start time")
287
+ criteria = scenario.get("criteria")
288
+ if criteria is not None:
289
+ if not isinstance(criteria, list):
290
+ raise ValueError(f"Scenario {number} criteria must be a list")
291
+ for item in criteria:
292
+ if (
293
+ not isinstance(item, dict)
294
+ or not isinstance(item.get("expected"), str)
295
+ or not item["expected"].strip()
296
+ ):
297
+ raise ValueError(
298
+ f"Scenario {number} has a criterion without an expected result"
299
+ )
300
+ if not isinstance(item.get("status"), str) or item["status"] not in STATUSES:
301
+ raise ValueError(f"Scenario {number} has a criterion with an unknown status")
302
+ for field in ("actual", "reason", "screenshot"):
303
+ if item.get(field) is not None and not isinstance(item[field], str):
304
+ raise ValueError(
305
+ f"Scenario {number} criterion field {field} must be a string"
306
+ )
307
+ required = "actual" if item["status"] in ("passed", "failed") else "reason"
308
+ if not (item.get(required) or "").strip():
309
+ raise ValueError(f"Scenario {number} criterion requires {required}")
310
+ validate_time(item, started, number)
311
+ if criteria and criteria_status(criteria) != scenario["status"]:
312
+ raise ValueError(f"Scenario {number} status does not match its criteria")
313
+ if not criteria and scenario["status"] in ("passed", "failed"):
314
+ raise ValueError(f"Scenario {number} has no acceptance criteria")
315
+ if not criteria:
316
+ if scenario["status"] in ("passed", "failed"):
317
+ if (
318
+ not (scenario.get("expected") or "").strip()
319
+ or not (scenario.get("actual") or "").strip()
320
+ ):
321
+ raise ValueError(f"Scenario {number} requires expected and actual")
322
+ elif not (scenario.get("reason") or "").strip():
323
+ raise ValueError(f"Scenario {number} requires reason")
324
+ for step in scenario.get("steps", []):
325
+ if (
326
+ not isinstance(step, dict)
327
+ or not isinstance(step.get("name"), str)
328
+ or not step["name"].strip()
329
+ ):
330
+ raise ValueError(f"Scenario {number} has a step without a name")
331
+ if step.get("status") is not None and (
332
+ not isinstance(step["status"], str) or step["status"] not in STATUSES
333
+ ):
334
+ raise ValueError(f"Scenario {number} has a step with an unknown status")
335
+ if step.get("detail") is not None and not isinstance(step["detail"], str):
336
+ raise ValueError(f"Scenario {number} step detail must be a string")
337
+ validate_time(step, started, number)
338
+
339
+
340
+ def time_button(item: dict, has_video: bool, started: int | float | None, labels: dict) -> str:
341
+ at = item.get("at")
342
+ if at is None and item.get("recorded_at") is not None:
343
+ at = item["recorded_at"] - started
344
+ if at is not None and has_video:
345
+ minutes, seconds = divmod(int(at), 60)
346
+ clock = f"{minutes}:{seconds:02d}"
347
+ return f'<button class="seek" type="button" data-time="{at:.3f}" aria-label="{text(labels["seek"])} {clock}">{clock}</button>'
348
+ return ""
349
+
350
+
351
+ def step_markup(step: dict, has_video: bool, started: int | float | None, labels: dict) -> str:
352
+ time = time_button(step, has_video, started, labels)
353
+ status = step.get("status")
354
+ label = f'<span class="step-status {status}">{text(labels[status])}</span>' if status else ""
355
+ detail = f'<p class="step-detail">{text(step["detail"])}</p>' if step.get("detail") else ""
356
+ return f'<li class="step"><div class="step-title">{time}<span>{text(step["name"])}</span>{label}</div>{detail}</li>'
357
+
358
+
359
+ def criterion_markup(
360
+ root: Path, item: dict, has_video: bool, started: int | float | None, labels: dict
361
+ ) -> str:
362
+ status = item["status"]
363
+ field = "actual" if status in ("passed", "failed") else "reason"
364
+ field_label = labels["observed"] if field == "actual" else labels["reason"]
365
+ evidence = asset_url(root, item.get("screenshot"))
366
+ evidence_link = f'<a href="{text(evidence)}">{text(labels["evidence"])}</a>' if evidence else ""
367
+ time = time_button(item, has_video, started, labels)
368
+ return (
369
+ f'<li class="criterion"><div class="criterion-heading"><span>{text(item["expected"])}</span>'
370
+ f'<span class="badge {status}">{text(labels[status])}</span></div>'
371
+ f'<p class="criterion-detail"><strong>{text(field_label)}:</strong> {text(item[field])}</p>'
372
+ f'<div class="criterion-evidence">{time}{evidence_link}</div></li>'
373
+ )
374
+
375
+
376
+ def scenario_panel(root: Path, scenario: dict, number: int, labels: dict) -> str:
377
+ status = scenario["status"]
378
+ source = ""
379
+ if scenario.get("source"):
380
+ source_url = asset_url(root, scenario["source"])
381
+ source_label = text(scenario["source"])
382
+ source = (
383
+ f'<p class="source"><a href="{text(source_url)}">{source_label}</a></p>'
384
+ if source_url
385
+ else f'<p class="source">{source_label}</p>'
386
+ )
387
+ expected = text(scenario.get("expected") or labels["not_specified"])
388
+ actual = text(scenario.get("actual") or labels["not_recorded"])
389
+ reason = f'<p class="reason">{text(scenario["reason"])}</p>' if scenario.get("reason") else ""
390
+ video, video_error = recording_url(root, scenario.get("video"))
391
+ if video:
392
+ video_block = (
393
+ f'<div class="video-wrap"><video controls preload="metadata" src="{text(video)}" '
394
+ f'aria-label="{text(labels["video_label"])}: {text(scenario["name"])}" aria-describedby="scenario-{number}-steps">'
395
+ f'{text(labels["video_unsupported"])}</video></div><a class="asset-link" href="{text(video)}">{text(labels["video_open"])}</a>'
396
+ )
397
+ else:
398
+ reasons = [labels[video_error]] if video_error else []
399
+ if scenario.get("video_reason"):
400
+ reasons.append(scenario["video_reason"])
401
+ explanation = f": {text('; '.join(reasons))}" if reasons else ""
402
+ video_block = f'<div class="missing">{text(labels["video_missing"])}{explanation}</div>'
403
+ steps = "".join(
404
+ step_markup(step, bool(video), scenario.get("video_started_at"), labels)
405
+ for step in scenario.get("steps", [])
406
+ )
407
+ steps_block = (
408
+ f'<ol class="steps">{steps}</ol>'
409
+ if steps
410
+ else f'<div class="missing">{text(labels["steps_missing"])}</div>'
411
+ )
412
+ criteria = scenario.get("criteria") or []
413
+ if criteria:
414
+ entries = "".join(
415
+ criterion_markup(root, item, bool(video), scenario.get("video_started_at"), labels)
416
+ for item in criteria
417
+ )
418
+ outcomes = f'<section class="section"><h3>{text(labels["criteria"])}</h3><ol class="criteria">{entries}</ol></section>'
419
+ else:
420
+ outcomes = (
421
+ f'<div class="outcomes"><div class="outcome"><div class="outcome-label">{text(labels["expected"])}</div><p>{expected}</p></div>'
422
+ f'<div class="outcome"><div class="outcome-label">{text(labels["actual"])}</div><p>{actual}</p></div></div>'
423
+ )
424
+ screenshot = asset_url(root, scenario.get("screenshot"))
425
+ if screenshot:
426
+ screenshot_block = (
427
+ f'<section class="section"><h3>{text(labels["screenshot"])}</h3><a href="{text(screenshot)}">'
428
+ f'<img class="evidence" src="{text(screenshot)}" alt="{text(labels["screenshot_alt"])}" loading="lazy"></a></section>'
429
+ )
430
+ elif scenario.get("screenshot"):
431
+ screenshot_block = f'<section class="section"><h3>{text(labels["screenshot"])}</h3><div class="missing">{text(labels["screenshot_missing"])}</div></section>'
432
+ else:
433
+ screenshot_block = ""
434
+ reproduction = scenario.get("reproduction", [])
435
+ reproduction_block = (
436
+ (
437
+ f"<details><summary>{text(labels['reproduction'])}</summary><ol>"
438
+ + "".join(f"<li>{text(item)}</li>" for item in reproduction)
439
+ + "</ol></details>"
440
+ )
441
+ if reproduction
442
+ else ""
443
+ )
444
+ return (
445
+ f'<section class="panel" id="scenario-{number}"><div class="panel-heading"><div>'
446
+ f'<h2>{text(scenario["name"])}</h2>{source}</div><span class="badge {status}">{text(labels[status])}</span></div>'
447
+ f'<section class="section"><h3>{text(labels["video"])}</h3>{video_block}</section>'
448
+ f"{outcomes}{reason}"
449
+ f'<section class="section" id="scenario-{number}-steps"><h3>{text(labels["steps"])}</h3>{steps_block}</section>{screenshot_block}{reproduction_block}</section>'
450
+ )
451
+
452
+
453
+ def render(run: dict, root: Path) -> str:
454
+ validate(run)
455
+ language = run.get("language", "en")
456
+ defaults = LEGACY_RU_LABELS if language.split("-", 1)[0].lower() == "ru" else LABELS
457
+ labels = defaults | run.get("labels", {})
458
+ scenarios = run["scenarios"]
459
+ counts = Counter(scenario["status"] for scenario in scenarios)
460
+ summary = "".join(
461
+ f'<div class="stat"><div class="stat-number {status}">{counts[status]}</div>'
462
+ f'<div class="stat-label">{text(labels[f"{status}_count"])}</div></div>'
463
+ for status in STATUSES
464
+ )
465
+ metadata = "".join(
466
+ f"<span>{text(value)}</span>"
467
+ for value in (run["site"].get("url"), run.get("started_at"), run.get("duration"))
468
+ if value
469
+ )
470
+ context = "".join(
471
+ f"<p><strong>{text(labels[field])}:</strong> {text(run[field])}</p>"
472
+ for field in ("scope", "assumptions", "limitations")
473
+ if run.get(field)
474
+ )
475
+ context = f'<div class="context">{context}</div>' if context else ""
476
+ if scenarios:
477
+ default = next(
478
+ (i for i, scenario in enumerate(scenarios, 1) if scenario["status"] == "failed"), 1
479
+ )
480
+ links = "".join(
481
+ f'<a class="scenario-link" href="#scenario-{i}"{(" data-default" if i == default else "")}>'
482
+ f'<span class="scenario-number">{i:02d}</span><span class="scenario-name">{text(scenario["name"])}'
483
+ f'<span class="scenario-status {scenario["status"]}">{text(labels[scenario["status"]])}</span></span></a>'
484
+ for i, scenario in enumerate(scenarios, 1)
485
+ )
486
+ panels = "".join(
487
+ scenario_panel(root, scenario, i, labels) for i, scenario in enumerate(scenarios, 1)
488
+ )
489
+ content = (
490
+ f'<div class="workspace"><aside class="sidebar"><div class="sidebar-heading">{text(labels["scenarios"])}'
491
+ f'<span>{len(scenarios)}</span></div><nav class="scenario-nav" aria-label="{text(labels["scenarios"])}">{links}</nav></aside>'
492
+ f"<div>{panels}</div></div>"
493
+ )
494
+ else:
495
+ content = f'<div class="empty">{text(labels["empty"])}</div>'
496
+ return Template(TEMPLATE.read_text(encoding="utf-8")).substitute(
497
+ page_title=text(run["site"]["name"]),
498
+ site_name=text(run["site"]["name"]),
499
+ metadata=metadata,
500
+ context=context,
501
+ summary=summary,
502
+ content=content,
503
+ language=text(language),
504
+ brand_note=text(labels["brand_note"]),
505
+ direction=text(run.get("direction", "ltr")),
506
+ report_label=text(labels["report"]),
507
+ summary_aria=text(labels["summary"]),
508
+ footnote=text(labels["footnote"]),
509
+ seek_outside=text(labels["seek_outside"]),
510
+ )
511
+
512
+
513
+ def main() -> int:
514
+ parser = argparse.ArgumentParser(description="Create a Test Drive report from a run")
515
+ parser.add_argument("run", type=Path, help="path to run.json inside the report directory")
516
+ args = parser.parse_args()
517
+ temporary = None
518
+ try:
519
+ run_file = args.run.resolve(strict=True)
520
+ output = run_file.parent / "report.html"
521
+ if output == run_file:
522
+ raise ValueError("Input file must not be named report.html")
523
+ run = json.loads(run_file.read_text(encoding="utf-8"))
524
+ report = render(run, run_file.parent)
525
+ with tempfile.NamedTemporaryFile(
526
+ mode="w",
527
+ encoding="utf-8",
528
+ dir=output.parent,
529
+ prefix=".report-",
530
+ suffix=".tmp",
531
+ delete=False,
532
+ ) as file:
533
+ temporary = Path(file.name)
534
+ file.write(report)
535
+ temporary.replace(output)
536
+ except (OSError, ValueError, KeyError) as error:
537
+ if temporary is not None:
538
+ try:
539
+ temporary.unlink(missing_ok=True)
540
+ except OSError:
541
+ print("Could not remove the temporary report file.", file=sys.stderr)
542
+ print(f"Could not create report: {error}", file=sys.stderr)
543
+ return 2
544
+ print(f"Report: {output}")
545
+ return 0
546
+
547
+
548
+ if __name__ == "__main__":
549
+ raise SystemExit(main())
@@ -0,0 +1,110 @@
1
+ """Check a site's index and Markdown scenarios without exposing their contents."""
2
+
3
+ import argparse
4
+ import json
5
+ import re
6
+ import sys
7
+ from pathlib import Path
8
+
9
+
10
+ def inspect(path: Path) -> dict:
11
+ result = {"path": str(path), "name": "", "errors": []}
12
+ try:
13
+ lines = path.read_text(encoding="utf-8").splitlines()
14
+ except (OSError, UnicodeError) as error:
15
+ result["errors"].append(f"Could not read file: {error}")
16
+ return result
17
+
18
+ fence = None
19
+ has_content = False
20
+ skip_underline = False
21
+ for index, line in enumerate(lines):
22
+ if skip_underline:
23
+ skip_underline = False
24
+ continue
25
+ marker = re.match(r"^\s{0,3}(`{3,}|~{3,})(.*)$", line)
26
+ if marker:
27
+ chars, tail = marker.groups()
28
+ if fence is None:
29
+ fence = (chars[0], len(chars))
30
+ elif chars[0] == fence[0] and len(chars) >= fence[1] and not tail.strip():
31
+ fence = None
32
+ continue
33
+ atx = re.match(r"^\s{0,3}#\s+(.+?)\s*#*\s*$", line) if fence is None else None
34
+ setext = (
35
+ fence is None
36
+ and not atx
37
+ and index + 1 < len(lines)
38
+ and line.strip()
39
+ and re.match(r"^ {0,3}\S", line)
40
+ and not re.match(r"^ {0,3}(?:[>#+*`~]|-\s|\d+[.)]\s)", line)
41
+ and re.fullmatch(r" {0,3}=+[ \t]*", lines[index + 1])
42
+ )
43
+ if atx or setext:
44
+ if result["name"]:
45
+ result["errors"].append("File has multiple top-level headings")
46
+ else:
47
+ result["name"] = atx.group(1) if atx else line.strip()
48
+ skip_underline = bool(setext)
49
+ elif (
50
+ result["name"]
51
+ and fence is None
52
+ and line.strip()
53
+ and not re.match(r"^\s{0,3}#{1,6}\s", line)
54
+ ):
55
+ has_content = True
56
+
57
+ if not result["name"]:
58
+ result["errors"].append("Site or scenario title is missing")
59
+ elif not has_content:
60
+ result["errors"].append("File has no content outside headings and code examples")
61
+ return result
62
+
63
+
64
+ def check(path: Path) -> dict:
65
+ if path.is_dir():
66
+ directory = path
67
+ scenarios = sorted(file for file in directory.glob("*.md") if file.name != "index.md")
68
+ elif path.is_file() and path.name == "index.md":
69
+ return check(path.parent)
70
+ elif path.is_file() and path.suffix == ".md":
71
+ directory = path.parent
72
+ scenarios = [path]
73
+ else:
74
+ return {
75
+ "validation": "structure_only",
76
+ "site": {
77
+ "path": str(path),
78
+ "name": "",
79
+ "errors": ["Folder or Markdown file not found"],
80
+ },
81
+ "scenarios": [],
82
+ }
83
+
84
+ site = inspect(directory / "index.md")
85
+ cases = [inspect(file) for file in scenarios]
86
+ if not cases:
87
+ site["errors"].append("Folder has no scenario files")
88
+ return {"validation": "structure_only", "site": site, "scenarios": cases}
89
+
90
+
91
+ def main() -> int:
92
+ parser = argparse.ArgumentParser(
93
+ description="Check file structure; the agent checks meaning and acceptance criteria"
94
+ )
95
+ parser.add_argument("path", type=Path, help="site folder or individual scenario file")
96
+ args = parser.parse_args()
97
+ result = check(args.path)
98
+ output = json.dumps(result, ensure_ascii=False, indent=2)
99
+ try:
100
+ output.encode(sys.stdout.encoding or "utf-8")
101
+ except UnicodeEncodeError:
102
+ output = json.dumps(result, ensure_ascii=True, indent=2)
103
+ print(output)
104
+ return (
105
+ 2 if result["site"]["errors"] or any(case["errors"] for case in result["scenarios"]) else 0
106
+ )
107
+
108
+
109
+ if __name__ == "__main__":
110
+ raise SystemExit(main())