fusiontest 0.1.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.
- fusiontest/__init__.py +2 -0
- fusiontest/cli.py +477 -0
- fusiontest/core/__init__.py +0 -0
- fusiontest/core/action_model.py +382 -0
- fusiontest/core/goal_verifier.py +291 -0
- fusiontest/core/runner.py +310 -0
- fusiontest/core/screen_parser.py +283 -0
- fusiontest/desktop/__init__.py +0 -0
- fusiontest/desktop/macos_adapter.py +450 -0
- fusiontest/desktop/playwright_adapter.py +454 -0
- fusiontest/desktop/windows_adapter.py +423 -0
- fusiontest/guardrails/__init__.py +0 -0
- fusiontest/guardrails/engine.py +247 -0
- fusiontest/mobile/__init__.py +0 -0
- fusiontest/mobile/android_adapter.py +201 -0
- fusiontest/mobile/ios_adapter.py +266 -0
- fusiontest/recording/__init__.py +19 -0
- fusiontest/recording/recorder.py +309 -0
- fusiontest/reporting/__init__.py +0 -0
- fusiontest/reporting/reporter.py +201 -0
- fusiontest/training/__init__.py +35 -0
- fusiontest/training/data_collector.py +241 -0
- fusiontest/training/dataset_builder.py +210 -0
- fusiontest/training/trainer.py +336 -0
- fusiontest-0.1.0.dist-info/METADATA +260 -0
- fusiontest-0.1.0.dist-info/RECORD +29 -0
- fusiontest-0.1.0.dist-info/WHEEL +5 -0
- fusiontest-0.1.0.dist-info/entry_points.txt +4 -0
- fusiontest-0.1.0.dist-info/top_level.txt +1 -0
fusiontest/__init__.py
ADDED
fusiontest/cli.py
ADDED
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
"""
|
|
2
|
+
fusiontest/cli.py
|
|
3
|
+
|
|
4
|
+
The `fusiontest` CLI — run tests from the command line or CI.
|
|
5
|
+
|
|
6
|
+
Commands:
|
|
7
|
+
fusiontest run — run a test goal or goal file
|
|
8
|
+
fusiontest demo — run a pre-built demo against a public site (no app needed)
|
|
9
|
+
fusiontest init — scaffold a fusiontest.config.yaml in the current directory
|
|
10
|
+
fusiontest report — print the last run result
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import sys
|
|
17
|
+
import time
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
import click
|
|
21
|
+
import yaml
|
|
22
|
+
from rich.console import Console
|
|
23
|
+
from rich.panel import Panel
|
|
24
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
25
|
+
from rich.table import Table
|
|
26
|
+
|
|
27
|
+
console = Console()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@click.group()
|
|
31
|
+
@click.version_option(version="0.1.0", prog_name="FusionTest")
|
|
32
|
+
def main():
|
|
33
|
+
"""FusionTest — AI-powered UI testing for mobile and desktop.\n\nby FusionLeap.io"""
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ── fusiontest run ──────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
@main.command()
|
|
40
|
+
@click.option("--goal", "-g", multiple=True, help="Natural language test goal (repeatable)")
|
|
41
|
+
@click.option("--file", "-f", "goal_file", type=click.Path(exists=True), help="YAML file of goals")
|
|
42
|
+
@click.option("--platform", "-p",
|
|
43
|
+
type=click.Choice(["android", "ios", "playwright", "electron"]),
|
|
44
|
+
default="playwright", show_default=True)
|
|
45
|
+
@click.option("--app", type=click.Path(), help="Path to .apk / .ipa / Electron binary")
|
|
46
|
+
@click.option("--url", help="URL to open (for playwright/web targets)")
|
|
47
|
+
@click.option("--locale", default="en-US", show_default=True, help="Locale for the test run")
|
|
48
|
+
@click.option("--backend", type=click.Choice(["gpt4o", "claude", "mpnet"]),
|
|
49
|
+
default="gpt4o", show_default=True, help="AI model backend")
|
|
50
|
+
@click.option("--headless/--no-headless", default=False, show_default=True)
|
|
51
|
+
@click.option("--output", "-o", type=click.Path(), help="Write JSON report to this path")
|
|
52
|
+
@click.option("--config", "config_file", type=click.Path(exists=True),
|
|
53
|
+
default="fusiontest.config.yaml", help="Config file path")
|
|
54
|
+
def run(goal, goal_file, platform, app, url, locale, backend, headless, output, config_file):
|
|
55
|
+
"""Run a FusionTest test — provide goals via --goal or --file."""
|
|
56
|
+
|
|
57
|
+
from dotenv import load_dotenv
|
|
58
|
+
load_dotenv(override=True)
|
|
59
|
+
|
|
60
|
+
# Load config file if it exists
|
|
61
|
+
cfg = {}
|
|
62
|
+
if config_file and Path(config_file).exists():
|
|
63
|
+
with open(config_file) as f:
|
|
64
|
+
cfg = yaml.safe_load(f) or {}
|
|
65
|
+
|
|
66
|
+
# Resolve goals
|
|
67
|
+
goals = list(goal)
|
|
68
|
+
if goal_file:
|
|
69
|
+
with open(goal_file) as f:
|
|
70
|
+
data = yaml.safe_load(f)
|
|
71
|
+
goals = data.get("goals", [])
|
|
72
|
+
platform = data.get("platform", platform)
|
|
73
|
+
locale = data.get("locale", locale)
|
|
74
|
+
if not url and data.get("url"):
|
|
75
|
+
url = data["url"]
|
|
76
|
+
|
|
77
|
+
if not goals:
|
|
78
|
+
console.print("[red]Error:[/red] No goals provided. Use --goal or --file.")
|
|
79
|
+
sys.exit(1)
|
|
80
|
+
|
|
81
|
+
# Print header
|
|
82
|
+
console.print()
|
|
83
|
+
console.print(Panel.fit(
|
|
84
|
+
f"[bold]FusionTest[/bold] by FusionLeap.io\n"
|
|
85
|
+
f"Platform: [cyan]{platform}[/cyan] · Backend: [cyan]{backend}[/cyan] · Locale: [cyan]{locale}[/cyan]",
|
|
86
|
+
border_style="blue",
|
|
87
|
+
))
|
|
88
|
+
console.print(f"\n[bold]Goals ({len(goals)}):[/bold]")
|
|
89
|
+
for i, g in enumerate(goals, 1):
|
|
90
|
+
console.print(f" {i}. {g}")
|
|
91
|
+
console.print()
|
|
92
|
+
|
|
93
|
+
# Build adapter
|
|
94
|
+
adapter = _build_adapter(platform, app, url, headless)
|
|
95
|
+
|
|
96
|
+
# Build runner
|
|
97
|
+
from fusiontest.core.runner import FusionTestRunner, RunnerConfig
|
|
98
|
+
from fusiontest.guardrails.engine import GuardrailsConfig
|
|
99
|
+
|
|
100
|
+
runner_cfg = RunnerConfig(
|
|
101
|
+
backend=backend,
|
|
102
|
+
guardrails=GuardrailsConfig(
|
|
103
|
+
loop_window=cfg.get("guardrails", {}).get("loop_detection_window", 5),
|
|
104
|
+
max_invalid_retries=cfg.get("guardrails", {}).get("max_retries", 3),
|
|
105
|
+
),
|
|
106
|
+
screenshot_on_failure=True,
|
|
107
|
+
)
|
|
108
|
+
runner = FusionTestRunner(adapter, runner_cfg)
|
|
109
|
+
|
|
110
|
+
# Run with live progress
|
|
111
|
+
with Progress(
|
|
112
|
+
SpinnerColumn(),
|
|
113
|
+
TextColumn("[progress.description]{task.description}"),
|
|
114
|
+
console=console,
|
|
115
|
+
) as progress:
|
|
116
|
+
progress.add_task("Running tests...", total=None)
|
|
117
|
+
time.time()
|
|
118
|
+
try:
|
|
119
|
+
result = runner.run(goals=goals, platform=platform, locale=locale)
|
|
120
|
+
except KeyboardInterrupt:
|
|
121
|
+
console.print("\n[yellow]Interrupted by user[/yellow]")
|
|
122
|
+
sys.exit(1)
|
|
123
|
+
finally:
|
|
124
|
+
progress.stop()
|
|
125
|
+
|
|
126
|
+
# Print results table
|
|
127
|
+
_print_results(result)
|
|
128
|
+
|
|
129
|
+
# Save JSON report
|
|
130
|
+
if output:
|
|
131
|
+
_save_report(result, output)
|
|
132
|
+
console.print(f"\n[dim]Report saved to: {output}[/dim]")
|
|
133
|
+
|
|
134
|
+
sys.exit(0 if result.success else 1)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# ── fusiontest demo ─────────────────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
# Maps demo target names → (goal file path relative to package root, description)
|
|
140
|
+
_DEMOS: dict[str, tuple[str, str]] = {
|
|
141
|
+
"dashboard": ("tests/goals/demos/fusiontest_dashboard.yaml",
|
|
142
|
+
"FusionTest dashboard self-test (requires: fusiontest-api + npm run dev)"),
|
|
143
|
+
"todomvc": ("tests/goals/demos/todomvc.yaml",
|
|
144
|
+
"TodoMVC — full CRUD flow on the React TodoMVC demo app"),
|
|
145
|
+
"wikipedia": ("tests/goals/demos/wikipedia.yaml",
|
|
146
|
+
"Wikipedia — search, navigate, and read an article"),
|
|
147
|
+
"hackernews": ("tests/goals/demos/hacker_news.yaml",
|
|
148
|
+
"Hacker News — front page browsing and comment navigation"),
|
|
149
|
+
"github": ("tests/goals/demos/github.yaml",
|
|
150
|
+
"GitHub — browse the fusion-test public repo (no login)"),
|
|
151
|
+
"httpbin": ("tests/goals/demos/httpbin.yaml",
|
|
152
|
+
"httpbin.org — HTTP form submission and response inspection"),
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@main.command()
|
|
157
|
+
@click.argument("target", required=False)
|
|
158
|
+
@click.option("--backend", type=click.Choice(["gpt4o", "claude", "mpnet"]),
|
|
159
|
+
default="gpt4o", show_default=True)
|
|
160
|
+
@click.option("--headless/--no-headless", default=False, show_default=True)
|
|
161
|
+
@click.option("--output", "-o", type=click.Path(), help="Write JSON report to this path")
|
|
162
|
+
@click.option("--list", "list_demos", is_flag=True, help="List all available demo targets")
|
|
163
|
+
def demo(target, backend, headless, output, list_demos):
|
|
164
|
+
"""Run a pre-built demo against a public site — no app setup required.
|
|
165
|
+
|
|
166
|
+
\b
|
|
167
|
+
Available targets:
|
|
168
|
+
dashboard FusionTest dashboard self-test
|
|
169
|
+
todomvc TodoMVC React CRUD flow
|
|
170
|
+
wikipedia Wikipedia search and navigation
|
|
171
|
+
hackernews Hacker News browsing
|
|
172
|
+
github GitHub public repo browsing
|
|
173
|
+
httpbin httpbin.org form submission
|
|
174
|
+
|
|
175
|
+
\b
|
|
176
|
+
Examples:
|
|
177
|
+
fusiontest demo todomvc
|
|
178
|
+
fusiontest demo wikipedia --backend claude
|
|
179
|
+
fusiontest demo --list
|
|
180
|
+
"""
|
|
181
|
+
from dotenv import load_dotenv
|
|
182
|
+
load_dotenv()
|
|
183
|
+
|
|
184
|
+
if list_demos or not target:
|
|
185
|
+
console.print()
|
|
186
|
+
console.print(Panel.fit(
|
|
187
|
+
"[bold]FusionTest Demo Targets[/bold]\n"
|
|
188
|
+
"Run any of these with: [cyan]fusiontest demo <target>[/cyan]",
|
|
189
|
+
border_style="blue",
|
|
190
|
+
))
|
|
191
|
+
console.print()
|
|
192
|
+
table = Table(show_header=True, header_style="bold", box=None, pad_edge=False)
|
|
193
|
+
table.add_column("Target", style="cyan", width=14)
|
|
194
|
+
table.add_column("Description")
|
|
195
|
+
for name, (_, desc) in _DEMOS.items():
|
|
196
|
+
table.add_row(name, desc)
|
|
197
|
+
console.print(table)
|
|
198
|
+
console.print()
|
|
199
|
+
if not target:
|
|
200
|
+
sys.exit(0)
|
|
201
|
+
|
|
202
|
+
target = target.lower().replace("-", "").replace("_", "")
|
|
203
|
+
# Allow partial matches (e.g. "todo" → "todomvc", "hack" → "hackernews")
|
|
204
|
+
matched = [k for k in _DEMOS if k.startswith(target)]
|
|
205
|
+
if not matched:
|
|
206
|
+
console.print(f"[red]Unknown demo target:[/red] {target}")
|
|
207
|
+
console.print("Run [cyan]fusiontest demo --list[/cyan] to see all available targets.")
|
|
208
|
+
sys.exit(1)
|
|
209
|
+
if len(matched) > 1:
|
|
210
|
+
console.print(f"[yellow]Ambiguous target '{target}':[/yellow] matches {matched}")
|
|
211
|
+
sys.exit(1)
|
|
212
|
+
|
|
213
|
+
demo_key = matched[0]
|
|
214
|
+
goal_rel_path, description = _DEMOS[demo_key]
|
|
215
|
+
|
|
216
|
+
# Resolve path: first relative to repo root, then as an installed package resource
|
|
217
|
+
repo_root = Path(__file__).parent.parent
|
|
218
|
+
goal_path = repo_root / goal_rel_path
|
|
219
|
+
if not goal_path.exists():
|
|
220
|
+
console.print(f"[red]Demo goal file not found:[/red] {goal_path}")
|
|
221
|
+
sys.exit(1)
|
|
222
|
+
|
|
223
|
+
with open(goal_path) as f:
|
|
224
|
+
data = yaml.safe_load(f)
|
|
225
|
+
|
|
226
|
+
goals = data.get("goals", [])
|
|
227
|
+
platform = data.get("platform", "playwright")
|
|
228
|
+
locale = data.get("locale", "en-US")
|
|
229
|
+
url = data.get("url")
|
|
230
|
+
name = data.get("name", description)
|
|
231
|
+
|
|
232
|
+
# Dashboard self-test: warn if services aren't running
|
|
233
|
+
if demo_key == "dashboard":
|
|
234
|
+
console.print()
|
|
235
|
+
console.print(Panel(
|
|
236
|
+
"[bold yellow]Dashboard self-test requires two services to be running:[/bold yellow]\n\n"
|
|
237
|
+
" Terminal 1: [cyan]fusiontest-api[/cyan]\n"
|
|
238
|
+
" Terminal 2: [cyan]cd dashboard && npm run dev[/cyan]\n\n"
|
|
239
|
+
"Then press Enter to continue, or Ctrl-C to cancel.",
|
|
240
|
+
border_style="yellow",
|
|
241
|
+
))
|
|
242
|
+
try:
|
|
243
|
+
input()
|
|
244
|
+
except KeyboardInterrupt:
|
|
245
|
+
console.print("\n[yellow]Cancelled.[/yellow]")
|
|
246
|
+
sys.exit(0)
|
|
247
|
+
|
|
248
|
+
console.print()
|
|
249
|
+
console.print(Panel.fit(
|
|
250
|
+
f"[bold]FusionTest Demo[/bold] — {name}\n"
|
|
251
|
+
f"Platform: [cyan]{platform}[/cyan] · Backend: [cyan]{backend}[/cyan] · "
|
|
252
|
+
f"URL: [cyan]{url or '(none)'}[/cyan]",
|
|
253
|
+
border_style="blue",
|
|
254
|
+
))
|
|
255
|
+
console.print(f"\n[bold]Goals ({len(goals)}):[/bold]")
|
|
256
|
+
for i, g in enumerate(goals, 1):
|
|
257
|
+
console.print(f" {i}. {g}")
|
|
258
|
+
console.print()
|
|
259
|
+
|
|
260
|
+
adapter = _build_adapter(platform, None, url, headless)
|
|
261
|
+
|
|
262
|
+
from fusiontest.core.runner import FusionTestRunner, RunnerConfig
|
|
263
|
+
runner = FusionTestRunner(adapter, RunnerConfig(backend=backend))
|
|
264
|
+
|
|
265
|
+
with Progress(SpinnerColumn(), TextColumn("[progress.description]{task.description}"),
|
|
266
|
+
console=console) as progress:
|
|
267
|
+
progress.add_task(f"Running {demo_key} demo...", total=None)
|
|
268
|
+
try:
|
|
269
|
+
result = runner.run(goals=goals, name=name, platform=platform, locale=locale)
|
|
270
|
+
except KeyboardInterrupt:
|
|
271
|
+
console.print("\n[yellow]Interrupted.[/yellow]")
|
|
272
|
+
sys.exit(1)
|
|
273
|
+
finally:
|
|
274
|
+
progress.stop()
|
|
275
|
+
|
|
276
|
+
_print_results(result)
|
|
277
|
+
|
|
278
|
+
if output:
|
|
279
|
+
_save_report(result, output)
|
|
280
|
+
console.print(f"\n[dim]Report saved to: {output}[/dim]")
|
|
281
|
+
|
|
282
|
+
if result.success:
|
|
283
|
+
console.print(
|
|
284
|
+
f"\n[green]✓ Demo passed![/green] FusionTest successfully navigated "
|
|
285
|
+
f"[bold]{demo_key}[/bold] using the [bold]{backend}[/bold] backend.\n"
|
|
286
|
+
f"Run [cyan]fusiontest demo --list[/cyan] to try another target."
|
|
287
|
+
)
|
|
288
|
+
else:
|
|
289
|
+
console.print(
|
|
290
|
+
"\n[red]✗ Demo had failures.[/red] Check the output above for details.\n"
|
|
291
|
+
"Tip: re-run with [cyan]--no-headless[/cyan] to watch the browser."
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
sys.exit(0 if result.success else 1)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
# ── fusiontest init ─────────────────────────────────────────────────────────────
|
|
298
|
+
|
|
299
|
+
@main.command()
|
|
300
|
+
def init():
|
|
301
|
+
"""Scaffold a fusiontest.config.yaml in the current directory."""
|
|
302
|
+
config_path = Path("fusiontest.config.yaml")
|
|
303
|
+
if config_path.exists():
|
|
304
|
+
if not click.confirm(f"{config_path} already exists. Overwrite?"):
|
|
305
|
+
return
|
|
306
|
+
|
|
307
|
+
example_goals_path = Path("tests/goals/smoke.yaml")
|
|
308
|
+
example_goals_path.parent.mkdir(parents=True, exist_ok=True)
|
|
309
|
+
|
|
310
|
+
config = {
|
|
311
|
+
"model": {
|
|
312
|
+
"backbone": "gpt4o",
|
|
313
|
+
"max_steps_per_goal": 25,
|
|
314
|
+
},
|
|
315
|
+
"guardrails": {
|
|
316
|
+
"loop_detection": True,
|
|
317
|
+
"loop_detection_window": 5,
|
|
318
|
+
"max_retries": 3,
|
|
319
|
+
"backtrack_on_invalid": True,
|
|
320
|
+
},
|
|
321
|
+
"devices": {
|
|
322
|
+
"android": {"farm": "local"},
|
|
323
|
+
"desktop": {
|
|
324
|
+
"vision_fallback": True,
|
|
325
|
+
"playwright_for_electron": True,
|
|
326
|
+
},
|
|
327
|
+
},
|
|
328
|
+
"reporting": {
|
|
329
|
+
"save_recordings": True,
|
|
330
|
+
},
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
with open(config_path, "w") as f:
|
|
334
|
+
yaml.dump(config, f, default_flow_style=False, sort_keys=False)
|
|
335
|
+
|
|
336
|
+
smoke_goals = {
|
|
337
|
+
"name": "Smoke test",
|
|
338
|
+
"platform": "playwright",
|
|
339
|
+
"locale": "en-US",
|
|
340
|
+
"url": "https://your-app-url.com",
|
|
341
|
+
"goals": [
|
|
342
|
+
"Verify the page loads and the main navigation is visible",
|
|
343
|
+
"Click the login button and verify the login form appears",
|
|
344
|
+
],
|
|
345
|
+
}
|
|
346
|
+
with open(example_goals_path, "w") as f:
|
|
347
|
+
yaml.dump(smoke_goals, f, default_flow_style=False, sort_keys=False)
|
|
348
|
+
|
|
349
|
+
console.print(f"[green]✓[/green] Created {config_path}")
|
|
350
|
+
console.print(f"[green]✓[/green] Created {example_goals_path}")
|
|
351
|
+
console.print("\nNext steps:")
|
|
352
|
+
console.print(" 1. Edit fusiontest.config.yaml to set your model backend")
|
|
353
|
+
console.print(" 2. Set OPENAI_API_KEY or ANTHROPIC_API_KEY in .env")
|
|
354
|
+
console.print(" 3. Run: [bold]fusiontest run --file tests/goals/smoke.yaml[/bold]")
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
# ── fusiontest report ───────────────────────────────────────────────────────────
|
|
358
|
+
|
|
359
|
+
@main.command()
|
|
360
|
+
@click.argument("report_file", type=click.Path(exists=True))
|
|
361
|
+
def report(report_file):
|
|
362
|
+
"""Pretty-print a JSON report from a previous run."""
|
|
363
|
+
with open(report_file) as f:
|
|
364
|
+
data = json.load(f)
|
|
365
|
+
_print_report_data(data)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
# ── Helpers ────────────────────────────────────────────────────────────────────
|
|
369
|
+
|
|
370
|
+
def _build_adapter(platform: str, app: str | None, url: str | None, headless: bool):
|
|
371
|
+
if platform == "playwright":
|
|
372
|
+
from fusiontest.desktop.playwright_adapter import PlaywrightAdapter
|
|
373
|
+
adapter = PlaywrightAdapter(browser_type="chromium", headless=headless)
|
|
374
|
+
adapter.start(url=url)
|
|
375
|
+
return adapter
|
|
376
|
+
|
|
377
|
+
elif platform == "electron":
|
|
378
|
+
if not app:
|
|
379
|
+
console.print("[red]--app is required for electron platform[/red]")
|
|
380
|
+
sys.exit(1)
|
|
381
|
+
from fusiontest.desktop.playwright_adapter import PlaywrightAdapter
|
|
382
|
+
adapter = PlaywrightAdapter(electron_app_path=app, headless=headless)
|
|
383
|
+
adapter.start()
|
|
384
|
+
return adapter
|
|
385
|
+
|
|
386
|
+
elif platform == "android":
|
|
387
|
+
from fusiontest.mobile.android_adapter import AndroidAdapter
|
|
388
|
+
caps = {}
|
|
389
|
+
if app:
|
|
390
|
+
caps["app"] = app
|
|
391
|
+
adapter = AndroidAdapter(desired_caps=caps)
|
|
392
|
+
adapter.start()
|
|
393
|
+
return adapter
|
|
394
|
+
|
|
395
|
+
elif platform == "ios":
|
|
396
|
+
from fusiontest.mobile.ios_adapter import IOSAdapter
|
|
397
|
+
caps = {}
|
|
398
|
+
if app:
|
|
399
|
+
caps["app"] = app
|
|
400
|
+
adapter = IOSAdapter(desired_caps=caps)
|
|
401
|
+
adapter.start()
|
|
402
|
+
return adapter
|
|
403
|
+
|
|
404
|
+
else:
|
|
405
|
+
console.print(f"[red]Unknown platform: {platform}[/red]")
|
|
406
|
+
sys.exit(1)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _print_results(result) -> None:
|
|
410
|
+
status = "[green]PASS ✓[/green]" if result.success else "[red]FAIL ✗[/red]"
|
|
411
|
+
console.print(f"\n{'─'*50}")
|
|
412
|
+
console.print(f"Result: {status} Stability: [bold]{result.stability_score:.0%}[/bold] "
|
|
413
|
+
f"Duration: {result.total_duration_seconds:.1f}s")
|
|
414
|
+
console.print()
|
|
415
|
+
|
|
416
|
+
table = Table(show_header=True, header_style="bold", box=None, pad_edge=False)
|
|
417
|
+
table.add_column("#", style="dim", width=3)
|
|
418
|
+
table.add_column("Goal", min_width=30)
|
|
419
|
+
table.add_column("Status", width=8)
|
|
420
|
+
table.add_column("Steps", width=6)
|
|
421
|
+
table.add_column("Time", width=7)
|
|
422
|
+
|
|
423
|
+
for i, step in enumerate(result.steps, 1):
|
|
424
|
+
status_cell = "[green]PASS[/green]" if step.success else "[red]FAIL[/red]"
|
|
425
|
+
error_note = f"\n [dim red]{step.error}[/dim red]" if step.error else ""
|
|
426
|
+
table.add_row(
|
|
427
|
+
str(i),
|
|
428
|
+
step.goal + error_note,
|
|
429
|
+
status_cell,
|
|
430
|
+
str(step.steps_taken),
|
|
431
|
+
f"{step.duration_seconds:.1f}s",
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
console.print(table)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _save_report(result, path: str) -> None:
|
|
438
|
+
data = {
|
|
439
|
+
"name": result.name,
|
|
440
|
+
"platform": result.platform,
|
|
441
|
+
"locale": result.locale,
|
|
442
|
+
"success": result.success,
|
|
443
|
+
"stability_score": result.stability_score,
|
|
444
|
+
"total_duration_seconds": result.total_duration_seconds,
|
|
445
|
+
"steps": [
|
|
446
|
+
{
|
|
447
|
+
"goal": s.goal,
|
|
448
|
+
"success": s.success,
|
|
449
|
+
"steps_taken": s.steps_taken,
|
|
450
|
+
"actions": s.actions,
|
|
451
|
+
"error": s.error,
|
|
452
|
+
"duration_seconds": s.duration_seconds,
|
|
453
|
+
}
|
|
454
|
+
for s in result.steps
|
|
455
|
+
],
|
|
456
|
+
}
|
|
457
|
+
with open(path, "w") as f:
|
|
458
|
+
json.dump(data, f, indent=2)
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _print_report_data(data: dict) -> None:
|
|
462
|
+
console.print(Panel.fit(
|
|
463
|
+
f"[bold]{data['name']}[/bold]\n"
|
|
464
|
+
f"Platform: {data['platform']} · Locale: {data['locale']} · "
|
|
465
|
+
f"Stability: {data['stability_score']:.0%}",
|
|
466
|
+
border_style="blue" if data["success"] else "red",
|
|
467
|
+
))
|
|
468
|
+
for _i, step in enumerate(data.get("steps", []), 1):
|
|
469
|
+
icon = "✓" if step["success"] else "✗"
|
|
470
|
+
color = "green" if step["success"] else "red"
|
|
471
|
+
console.print(f" [{color}]{icon}[/{color}] {step['goal']} [{step['steps_taken']} steps, {step['duration_seconds']:.1f}s]")
|
|
472
|
+
if step.get("error"):
|
|
473
|
+
console.print(f" [dim red]{step['error']}[/dim red]")
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
if __name__ == "__main__":
|
|
477
|
+
main()
|
|
File without changes
|