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.
- test_drive_skill-0.1.0/LICENSE +21 -0
- test_drive_skill-0.1.0/PKG-INFO +21 -0
- test_drive_skill-0.1.0/README.md +10 -0
- test_drive_skill-0.1.0/pyproject.toml +51 -0
- test_drive_skill-0.1.0/pyproject.toml.orig +40 -0
- test_drive_skill-0.1.0/src/test_drive/__init__.py +1 -0
- test_drive_skill-0.1.0/src/test_drive/__main__.py +3 -0
- test_drive_skill-0.1.0/src/test_drive/agent_browser.py +69 -0
- test_drive_skill-0.1.0/src/test_drive/agent_browser_setup.py +127 -0
- test_drive_skill-0.1.0/src/test_drive/assets/report.html +141 -0
- test_drive_skill-0.1.0/src/test_drive/cli.py +31 -0
- test_drive_skill-0.1.0/src/test_drive/report.py +549 -0
- test_drive_skill-0.1.0/src/test_drive/scenarios.py +110 -0
|
@@ -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,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())
|