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 ADDED
@@ -0,0 +1,2 @@
1
+ """FusionTest — AI-powered UI testing for mobile and desktop."""
2
+ __version__ = "0.1.0"
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