quickstarted 0.2.0__py3-none-any.whl
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.
- quickstarted/__init__.py +24 -0
- quickstarted/_version.py +7 -0
- quickstarted/agents/__init__.py +4 -0
- quickstarted/agents/base.py +145 -0
- quickstarted/agents/claude.py +242 -0
- quickstarted/agents/gemini_agent.py +138 -0
- quickstarted/agents/openai_agent.py +167 -0
- quickstarted/agents/prompt.py +61 -0
- quickstarted/agents/registry.py +31 -0
- quickstarted/agents/replay.py +39 -0
- quickstarted/cli.py +250 -0
- quickstarted/docs.py +263 -0
- quickstarted/exec/__init__.py +89 -0
- quickstarted/exec/base.py +131 -0
- quickstarted/exec/docker.py +216 -0
- quickstarted/exec/local.py +16 -0
- quickstarted/exec/seatbelt.py +83 -0
- quickstarted/journey.py +182 -0
- quickstarted/net/__init__.py +5 -0
- quickstarted/net/proxy.py +274 -0
- quickstarted/net/proxy_main.py +53 -0
- quickstarted/pricing.py +93 -0
- quickstarted/report.py +206 -0
- quickstarted/results.py +166 -0
- quickstarted/run.py +240 -0
- quickstarted/sandbox.py +15 -0
- quickstarted/suite.py +203 -0
- quickstarted/trace.py +53 -0
- quickstarted/transport.py +88 -0
- quickstarted-0.2.0.dist-info/METADATA +256 -0
- quickstarted-0.2.0.dist-info/RECORD +34 -0
- quickstarted-0.2.0.dist-info/WHEEL +4 -0
- quickstarted-0.2.0.dist-info/entry_points.txt +3 -0
- quickstarted-0.2.0.dist-info/licenses/LICENSE +21 -0
quickstarted/pricing.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Optional cost estimation.
|
|
2
|
+
|
|
3
|
+
There are deliberately no built-in prices. Vendor rates change, a stale table
|
|
4
|
+
baked into a benchmarking tool would quietly misreport what a run costs, and a
|
|
5
|
+
wrong number here is worse than no number. Supply rates yourself:
|
|
6
|
+
|
|
7
|
+
QUICKSTARTED_PRICES=/path/to/prices.json
|
|
8
|
+
|
|
9
|
+
{
|
|
10
|
+
"claude-opus-5": {
|
|
11
|
+
"input": 5.0, "output": 25.0, "cache_write": 6.25, "cache_read": 0.5
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
Units are US dollars per million tokens, matching how vendors publish them.
|
|
16
|
+
Token counts are always reported; dollars appear only when you provide rates.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
PRICES_ENV = "QUICKSTARTED_PRICES"
|
|
27
|
+
_PER_MILLION = 1_000_000.0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class ModelPrice:
|
|
32
|
+
input: float = 0.0
|
|
33
|
+
output: float = 0.0
|
|
34
|
+
cache_write: float = 0.0
|
|
35
|
+
cache_read: float = 0.0
|
|
36
|
+
|
|
37
|
+
def cost(
|
|
38
|
+
self,
|
|
39
|
+
input_tokens: int,
|
|
40
|
+
output_tokens: int,
|
|
41
|
+
cache_write_tokens: int = 0,
|
|
42
|
+
cache_read_tokens: int = 0,
|
|
43
|
+
) -> float:
|
|
44
|
+
return (
|
|
45
|
+
input_tokens * self.input
|
|
46
|
+
+ output_tokens * self.output
|
|
47
|
+
+ cache_write_tokens * self.cache_write
|
|
48
|
+
+ cache_read_tokens * self.cache_read
|
|
49
|
+
) / _PER_MILLION
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class PriceBook:
|
|
53
|
+
def __init__(self, prices: dict[str, ModelPrice] | None = None):
|
|
54
|
+
self.prices = prices or {}
|
|
55
|
+
|
|
56
|
+
def __bool__(self) -> bool:
|
|
57
|
+
return bool(self.prices)
|
|
58
|
+
|
|
59
|
+
def for_model(self, model: str) -> ModelPrice | None:
|
|
60
|
+
if model in self.prices:
|
|
61
|
+
return self.prices[model]
|
|
62
|
+
# Adapters report names like "claude:claude-opus-5"; match the tail.
|
|
63
|
+
tail = model.rsplit(":", 1)[-1]
|
|
64
|
+
return self.prices.get(tail)
|
|
65
|
+
|
|
66
|
+
def estimate(self, model: str, outcome) -> float | None:
|
|
67
|
+
price = self.for_model(model)
|
|
68
|
+
if price is None:
|
|
69
|
+
return None
|
|
70
|
+
return price.cost(
|
|
71
|
+
outcome.input_tokens,
|
|
72
|
+
outcome.output_tokens,
|
|
73
|
+
outcome.cache_write_tokens,
|
|
74
|
+
outcome.cache_read_tokens,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
@classmethod
|
|
78
|
+
def load(cls, path: str | None = None) -> PriceBook:
|
|
79
|
+
path = path or os.environ.get(PRICES_ENV)
|
|
80
|
+
if not path:
|
|
81
|
+
return cls()
|
|
82
|
+
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
83
|
+
prices = {}
|
|
84
|
+
for model, fields in data.items():
|
|
85
|
+
if not isinstance(fields, dict):
|
|
86
|
+
raise ValueError(f"price entry for {model!r} must be a mapping")
|
|
87
|
+
prices[model] = ModelPrice(
|
|
88
|
+
input=float(fields.get("input", 0.0)),
|
|
89
|
+
output=float(fields.get("output", 0.0)),
|
|
90
|
+
cache_write=float(fields.get("cache_write", 0.0)),
|
|
91
|
+
cache_read=float(fields.get("cache_read", 0.0)),
|
|
92
|
+
)
|
|
93
|
+
return cls(prices)
|
quickstarted/report.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Human-readable reporting: console summaries and markdown.
|
|
2
|
+
|
|
3
|
+
Reports name the classification, not just PASS/FAIL, because "we could not
|
|
4
|
+
reach PyPI" and "your quickstart is missing a step" require different people
|
|
5
|
+
to do different things.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from .pricing import PriceBook
|
|
11
|
+
from .run import DOCS_GAP, PASSED, RunResult
|
|
12
|
+
from .suite import JourneyStats, SuiteResult
|
|
13
|
+
|
|
14
|
+
_LABELS = {
|
|
15
|
+
PASSED: "PASS",
|
|
16
|
+
DOCS_GAP: "FAIL",
|
|
17
|
+
"budget_exhausted": "INCONCLUSIVE",
|
|
18
|
+
"infra_error": "INCONCLUSIVE",
|
|
19
|
+
"harness_error": "INCONCLUSIVE",
|
|
20
|
+
"agent_refusal": "INCONCLUSIVE",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def label(run: RunResult) -> str:
|
|
25
|
+
return _LABELS.get(run.classification, "INCONCLUSIVE")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _token_line(outcome) -> str:
|
|
29
|
+
"""Tokens for one run, cache traffic included so the number reflects cost."""
|
|
30
|
+
parts = []
|
|
31
|
+
if outcome.input_tokens or outcome.output_tokens:
|
|
32
|
+
parts.append(f"{outcome.input_tokens} in / {outcome.output_tokens} out")
|
|
33
|
+
if outcome.cache_write_tokens or outcome.cache_read_tokens:
|
|
34
|
+
parts.append(
|
|
35
|
+
f"cache {outcome.cache_write_tokens} written / "
|
|
36
|
+
f"{outcome.cache_read_tokens} read"
|
|
37
|
+
)
|
|
38
|
+
return ", ".join(parts)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def console_summary(result: RunResult) -> str:
|
|
42
|
+
lines = [
|
|
43
|
+
f"[{label(result)}] {result.journey.name} ({result.agent_name})",
|
|
44
|
+
f" classification: {result.classification}",
|
|
45
|
+
f" stop reason: {result.outcome.stop_reason}"
|
|
46
|
+
+ (f" ({result.outcome.detail})" if result.outcome.detail else ""),
|
|
47
|
+
f" turns: {result.outcome.turns}, duration: {result.duration:.1f}s",
|
|
48
|
+
f" backend: {result.backend}"
|
|
49
|
+
+ ("" if result.enforced else " [UNENFORCED: policy is advisory here]"),
|
|
50
|
+
]
|
|
51
|
+
tokens = _token_line(result.outcome)
|
|
52
|
+
if tokens:
|
|
53
|
+
lines.append(f" tokens: {tokens}")
|
|
54
|
+
fetched = result.trace.fetched_urls()
|
|
55
|
+
if fetched:
|
|
56
|
+
lines.append(f" docs pages read: {len(fetched)}")
|
|
57
|
+
if result.bypass_attempts:
|
|
58
|
+
lines.append(
|
|
59
|
+
f" docs fetched outside read_docs: {result.bypass_attempts} blocked attempt(s)"
|
|
60
|
+
)
|
|
61
|
+
if not result.passed:
|
|
62
|
+
if result.score is not None:
|
|
63
|
+
check_line = (result.score.output or "").strip().splitlines()
|
|
64
|
+
lines.append(
|
|
65
|
+
f" success check exit code: {result.score.exit_code}"
|
|
66
|
+
+ (f" ({check_line[-1]})" if check_line else "")
|
|
67
|
+
)
|
|
68
|
+
if result.classification == DOCS_GAP and result.suspect_page:
|
|
69
|
+
lines.append(f" last docs page read before failure: {result.suspect_page}")
|
|
70
|
+
return "\n".join(lines)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def suite_summary(suite: SuiteResult, prices: PriceBook | None = None) -> str:
|
|
74
|
+
prices = prices or PriceBook()
|
|
75
|
+
lines = ["", "=" * 62, f"Suite: {len(suite.stats)} journey(s), repeat={suite.repeat}"]
|
|
76
|
+
for stat in suite.stats:
|
|
77
|
+
rate = stat.pass_rate
|
|
78
|
+
shown = "no evidence" if rate is None else f"{rate:.0%}"
|
|
79
|
+
lines.append(
|
|
80
|
+
f" {stat.journey} ({stat.agent}): pass rate {shown} "
|
|
81
|
+
f"[{stat.passes}/{len(stat.evidential)} evidential of {stat.attempts} run(s)]"
|
|
82
|
+
)
|
|
83
|
+
if stat.discarded:
|
|
84
|
+
detail = ", ".join(f"{k}={v}" for k, v in sorted(stat.discarded.items()))
|
|
85
|
+
lines.append(f" discarded: {detail}")
|
|
86
|
+
if len(stat.models_seen) > 1:
|
|
87
|
+
lines.append(
|
|
88
|
+
f" WARNING: more than one model served this journey "
|
|
89
|
+
f"({', '.join(stat.models_seen)}); do not compare these runs"
|
|
90
|
+
)
|
|
91
|
+
for page, count in list(stat.suspect_pages.items())[:3]:
|
|
92
|
+
lines.append(f" failed after: {page} ({count}x)")
|
|
93
|
+
totals = suite.tokens()
|
|
94
|
+
lines.append(
|
|
95
|
+
f" tokens: {totals['input']} in / {totals['output']} out, "
|
|
96
|
+
f"cache {totals['cache_write']} written / {totals['cache_read']} read"
|
|
97
|
+
)
|
|
98
|
+
cost = suite.cost(prices)
|
|
99
|
+
if cost is not None:
|
|
100
|
+
lines.append(f" estimated cost: ${cost:.4f}")
|
|
101
|
+
lines.append(f" wall clock: {suite.duration:.1f}s")
|
|
102
|
+
lines.append("=" * 62)
|
|
103
|
+
return "\n".join(lines)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def markdown_report(result: RunResult) -> str:
|
|
107
|
+
out = [
|
|
108
|
+
f"# quickstarted: {result.journey.name}",
|
|
109
|
+
"",
|
|
110
|
+
f"**Result: {label(result)}** (`{result.classification}`, agent: "
|
|
111
|
+
f"`{result.agent_name}`)",
|
|
112
|
+
"",
|
|
113
|
+
f"- Goal: {result.journey.goal}",
|
|
114
|
+
f"- Stop reason: {result.outcome.stop_reason}"
|
|
115
|
+
+ (f" ({result.outcome.detail})" if result.outcome.detail else ""),
|
|
116
|
+
f"- Turns: {result.outcome.turns}",
|
|
117
|
+
f"- Duration: {result.duration:.1f}s",
|
|
118
|
+
f"- Backend: `{result.backend}` "
|
|
119
|
+
+ ("(enforced)" if result.enforced else "(**unenforced**)"),
|
|
120
|
+
]
|
|
121
|
+
if result.model_reported:
|
|
122
|
+
out.append(f"- Model served: `{result.model_reported}`")
|
|
123
|
+
tokens = _token_line(result.outcome)
|
|
124
|
+
if tokens:
|
|
125
|
+
out.append(f"- Tokens: {tokens}")
|
|
126
|
+
out.append("")
|
|
127
|
+
if not result.evidential:
|
|
128
|
+
out += [
|
|
129
|
+
"## This run is not evidence about the documentation",
|
|
130
|
+
"",
|
|
131
|
+
f"It was classified `{result.classification}`, which means the run "
|
|
132
|
+
"failed for a reason unrelated to whether the docs are usable. It "
|
|
133
|
+
"is excluded from pass rates rather than counted as a failure.",
|
|
134
|
+
"",
|
|
135
|
+
]
|
|
136
|
+
fetched = result.trace.fetched_urls()
|
|
137
|
+
if fetched:
|
|
138
|
+
out += ["## Docs pages the agent read", ""]
|
|
139
|
+
out += [f"1. {url}" for url in fetched]
|
|
140
|
+
out.append("")
|
|
141
|
+
if result.bypass_attempts:
|
|
142
|
+
out += [
|
|
143
|
+
"## Blocked shell access to documentation",
|
|
144
|
+
"",
|
|
145
|
+
f"The agent tried {result.bypass_attempts} time(s) to fetch docs "
|
|
146
|
+
"through the shell instead of the recorded tool. The proxy refused, "
|
|
147
|
+
"so the page list above is complete.",
|
|
148
|
+
"",
|
|
149
|
+
]
|
|
150
|
+
if result.score is not None:
|
|
151
|
+
out += [
|
|
152
|
+
"## Success check",
|
|
153
|
+
"",
|
|
154
|
+
f"Exit code: {result.score.exit_code}",
|
|
155
|
+
"",
|
|
156
|
+
"```",
|
|
157
|
+
(result.score.output or "").strip(),
|
|
158
|
+
"```",
|
|
159
|
+
"",
|
|
160
|
+
]
|
|
161
|
+
if result.classification == DOCS_GAP and result.suspect_page:
|
|
162
|
+
out += [
|
|
163
|
+
"## Where to look first",
|
|
164
|
+
"",
|
|
165
|
+
f"The last documentation page the agent read before failing was "
|
|
166
|
+
f"<{result.suspect_page}>. That page (or a gap right after it) is "
|
|
167
|
+
f"the first suspect.",
|
|
168
|
+
"",
|
|
169
|
+
]
|
|
170
|
+
return "\n".join(out)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def markdown_suite_report(suite: SuiteResult, prices: PriceBook | None = None) -> str:
|
|
174
|
+
prices = prices or PriceBook()
|
|
175
|
+
out = [
|
|
176
|
+
"# quickstarted suite",
|
|
177
|
+
"",
|
|
178
|
+
f"- Journeys: {len(suite.stats)}",
|
|
179
|
+
f"- Attempts per journey: {suite.repeat}",
|
|
180
|
+
f"- Backend: `{suite.backend}`",
|
|
181
|
+
f"- Duration: {suite.duration:.1f}s",
|
|
182
|
+
"",
|
|
183
|
+
"| Journey | Agent | Pass rate | Evidential | Discarded |",
|
|
184
|
+
"| --- | --- | --- | --- | --- |",
|
|
185
|
+
]
|
|
186
|
+
for stat in suite.stats:
|
|
187
|
+
rate = stat.pass_rate
|
|
188
|
+
shown = "n/a" if rate is None else f"{rate:.0%}"
|
|
189
|
+
discarded = (
|
|
190
|
+
", ".join(f"{k}={v}" for k, v in sorted(stat.discarded.items())) or "none"
|
|
191
|
+
)
|
|
192
|
+
out.append(
|
|
193
|
+
f"| {stat.journey} | `{stat.agent}` | {shown} | "
|
|
194
|
+
f"{stat.passes}/{len(stat.evidential)} | {discarded} |"
|
|
195
|
+
)
|
|
196
|
+
out.append("")
|
|
197
|
+
cost = suite.cost(prices)
|
|
198
|
+
if cost is not None:
|
|
199
|
+
out.append(f"Estimated cost: ${cost:.4f}")
|
|
200
|
+
out.append("")
|
|
201
|
+
return "\n".join(out)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def stats_line(stat: JourneyStats) -> str:
|
|
205
|
+
rate = stat.pass_rate
|
|
206
|
+
return f"{stat.journey}: {'n/a' if rate is None else f'{rate:.0%}'}"
|
quickstarted/results.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Machine-readable results: a versioned JSON document and JUnit XML.
|
|
2
|
+
|
|
3
|
+
`SCHEMA_VERSION` is a promise. The hosted product, the benchmark report, and
|
|
4
|
+
anyone's CI dashboard all parse this document, so fields get added but not
|
|
5
|
+
repurposed, and the version goes up when that stops being true.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import platform
|
|
12
|
+
import socket
|
|
13
|
+
import sys
|
|
14
|
+
import time
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from xml.etree import ElementTree as ET
|
|
17
|
+
|
|
18
|
+
from ._version import __version__
|
|
19
|
+
from .pricing import PriceBook
|
|
20
|
+
from .run import PASSED, RunResult
|
|
21
|
+
from .suite import SuiteResult
|
|
22
|
+
|
|
23
|
+
SCHEMA_VERSION = "1.0"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _run_document(run: RunResult) -> dict:
|
|
27
|
+
return {
|
|
28
|
+
"journey": run.journey.name,
|
|
29
|
+
"attempt": run.attempt,
|
|
30
|
+
"agent": run.agent_name,
|
|
31
|
+
"model_reported": run.model_reported,
|
|
32
|
+
"classification": run.classification,
|
|
33
|
+
"passed": run.passed,
|
|
34
|
+
"evidential": run.evidential,
|
|
35
|
+
"stop_reason": run.outcome.stop_reason,
|
|
36
|
+
"turns": run.outcome.turns,
|
|
37
|
+
"duration_seconds": round(run.duration, 2),
|
|
38
|
+
"backend": run.backend,
|
|
39
|
+
"enforced": run.enforced,
|
|
40
|
+
"docs_pages_read": run.trace.fetched_urls(),
|
|
41
|
+
"suspect_page": run.suspect_page,
|
|
42
|
+
"docs_bypass_attempts": run.bypass_attempts,
|
|
43
|
+
"success_check": (
|
|
44
|
+
None
|
|
45
|
+
if run.score is None
|
|
46
|
+
else {"exit_code": run.score.exit_code, "output": run.score.output[-2000:]}
|
|
47
|
+
),
|
|
48
|
+
"tokens": {
|
|
49
|
+
"input": run.outcome.input_tokens,
|
|
50
|
+
"output": run.outcome.output_tokens,
|
|
51
|
+
"cache_write": run.outcome.cache_write_tokens,
|
|
52
|
+
"cache_read": run.outcome.cache_read_tokens,
|
|
53
|
+
},
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def suite_document(suite: SuiteResult, prices: PriceBook | None = None) -> dict:
|
|
58
|
+
prices = prices or PriceBook()
|
|
59
|
+
journeys = []
|
|
60
|
+
for stat in suite.stats:
|
|
61
|
+
journeys.append(
|
|
62
|
+
{
|
|
63
|
+
"journey": stat.journey,
|
|
64
|
+
"agent": stat.agent,
|
|
65
|
+
"attempts": stat.attempts,
|
|
66
|
+
"passes": stat.passes,
|
|
67
|
+
"evidential_runs": len(stat.evidential),
|
|
68
|
+
"pass_rate": stat.pass_rate,
|
|
69
|
+
"discarded": stat.discarded,
|
|
70
|
+
"models_reported": stat.models_seen,
|
|
71
|
+
"suspect_pages": stat.suspect_pages,
|
|
72
|
+
"tokens": stat.tokens(),
|
|
73
|
+
"estimated_cost_usd": stat.cost(prices),
|
|
74
|
+
"runs": [_run_document(r) for r in stat.runs],
|
|
75
|
+
}
|
|
76
|
+
)
|
|
77
|
+
return {
|
|
78
|
+
"schema_version": SCHEMA_VERSION,
|
|
79
|
+
"quickstarted_version": __version__,
|
|
80
|
+
"generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
81
|
+
"environment": {
|
|
82
|
+
"python": sys.version.split()[0],
|
|
83
|
+
"platform": platform.platform(),
|
|
84
|
+
"hostname": socket.gethostname(),
|
|
85
|
+
"backend": suite.backend,
|
|
86
|
+
},
|
|
87
|
+
"repeat": suite.repeat,
|
|
88
|
+
"duration_seconds": round(suite.duration, 2),
|
|
89
|
+
"totals": {
|
|
90
|
+
"runs": len(suite.runs),
|
|
91
|
+
"tokens": suite.tokens(),
|
|
92
|
+
"estimated_cost_usd": suite.cost(prices),
|
|
93
|
+
},
|
|
94
|
+
"journeys": journeys,
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def write_json(suite: SuiteResult, path: str | Path, prices=None) -> None:
|
|
99
|
+
path = Path(path)
|
|
100
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
101
|
+
path.write_text(
|
|
102
|
+
json.dumps(suite_document(suite, prices), indent=2) + "\n", encoding="utf-8"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def junit_xml(suite: SuiteResult) -> str:
|
|
107
|
+
"""JUnit XML so existing CI reporters can render a run with no new tooling."""
|
|
108
|
+
suites = ET.Element("testsuites", name="quickstarted")
|
|
109
|
+
total_failures = total_errors = total_tests = 0
|
|
110
|
+
for stat in suite.stats:
|
|
111
|
+
element = ET.SubElement(
|
|
112
|
+
suites,
|
|
113
|
+
"testsuite",
|
|
114
|
+
name=stat.journey,
|
|
115
|
+
tests=str(stat.attempts),
|
|
116
|
+
)
|
|
117
|
+
failures = errors = 0
|
|
118
|
+
for run in stat.runs:
|
|
119
|
+
case = ET.SubElement(
|
|
120
|
+
element,
|
|
121
|
+
"testcase",
|
|
122
|
+
classname=f"quickstarted.{stat.journey}",
|
|
123
|
+
name=f"{stat.agent} attempt {run.attempt}",
|
|
124
|
+
time=f"{run.duration:.2f}",
|
|
125
|
+
)
|
|
126
|
+
if run.classification == PASSED:
|
|
127
|
+
continue
|
|
128
|
+
if run.evidential:
|
|
129
|
+
failures += 1
|
|
130
|
+
node = ET.SubElement(
|
|
131
|
+
case, "failure", type=run.classification,
|
|
132
|
+
message=(
|
|
133
|
+
"success check exit "
|
|
134
|
+
f"{run.score.exit_code if run.score else 'n/a'}"
|
|
135
|
+
),
|
|
136
|
+
)
|
|
137
|
+
detail = [f"stop reason: {run.outcome.stop_reason}"]
|
|
138
|
+
if run.suspect_page:
|
|
139
|
+
detail.append(f"last docs page read: {run.suspect_page}")
|
|
140
|
+
if run.score:
|
|
141
|
+
detail.append(run.score.output[-1000:])
|
|
142
|
+
node.text = "\n".join(detail)
|
|
143
|
+
else:
|
|
144
|
+
# Not a docs failure: infrastructure, budget, or our own bug.
|
|
145
|
+
errors += 1
|
|
146
|
+
node = ET.SubElement(
|
|
147
|
+
case, "error", type=run.classification,
|
|
148
|
+
message=run.outcome.detail or run.classification,
|
|
149
|
+
)
|
|
150
|
+
node.text = run.outcome.detail
|
|
151
|
+
element.set("failures", str(failures))
|
|
152
|
+
element.set("errors", str(errors))
|
|
153
|
+
total_failures += failures
|
|
154
|
+
total_errors += errors
|
|
155
|
+
total_tests += stat.attempts
|
|
156
|
+
suites.set("tests", str(total_tests))
|
|
157
|
+
suites.set("failures", str(total_failures))
|
|
158
|
+
suites.set("errors", str(total_errors))
|
|
159
|
+
suites.set("time", f"{suite.duration:.2f}")
|
|
160
|
+
return ET.tostring(suites, encoding="unicode")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def write_junit(suite: SuiteResult, path: str | Path) -> None:
|
|
164
|
+
path = Path(path)
|
|
165
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
166
|
+
path.write_text(junit_xml(suite) + "\n", encoding="utf-8")
|