screenforge 0.4.1__tar.gz → 0.5.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.
Files changed (105) hide show
  1. {screenforge-0.4.1 → screenforge-0.5.0}/PKG-INFO +3 -1
  2. {screenforge-0.4.1 → screenforge-0.5.0}/README.md +2 -0
  3. screenforge-0.5.0/cli/_version.py +1 -0
  4. {screenforge-0.4.1 → screenforge-0.5.0}/cli/modes/action.py +13 -0
  5. {screenforge-0.4.1 → screenforge-0.5.0}/cli/modes/workflow.py +15 -0
  6. {screenforge-0.4.1 → screenforge-0.5.0}/cli/parser.py +15 -1
  7. screenforge-0.5.0/cli/playground_sink.py +202 -0
  8. {screenforge-0.4.1 → screenforge-0.5.0}/pyproject.toml +1 -1
  9. {screenforge-0.4.1 → screenforge-0.5.0}/screenforge.egg-info/PKG-INFO +3 -1
  10. {screenforge-0.4.1 → screenforge-0.5.0}/screenforge.egg-info/SOURCES.txt +4 -0
  11. {screenforge-0.4.1 → screenforge-0.5.0}/tests/test_parser.py +12 -0
  12. screenforge-0.5.0/tests/test_playground_app.py +313 -0
  13. screenforge-0.5.0/tests/test_playground_sink.py +423 -0
  14. screenforge-0.5.0/tests/test_playground_sink_integration.py +202 -0
  15. screenforge-0.4.1/cli/_version.py +0 -1
  16. {screenforge-0.4.1 → screenforge-0.5.0}/LICENSE +0 -0
  17. {screenforge-0.4.1 → screenforge-0.5.0}/cli/__init__.py +0 -0
  18. {screenforge-0.4.1 → screenforge-0.5.0}/cli/dispatch.py +0 -0
  19. {screenforge-0.4.1 → screenforge-0.5.0}/cli/doctor.py +0 -0
  20. {screenforge-0.4.1 → screenforge-0.5.0}/cli/modes/__init__.py +0 -0
  21. {screenforge-0.4.1 → screenforge-0.5.0}/cli/modes/default.py +0 -0
  22. {screenforge-0.4.1 → screenforge-0.5.0}/cli/modes/demo.py +0 -0
  23. {screenforge-0.4.1 → screenforge-0.5.0}/cli/modes/dry_run.py +0 -0
  24. {screenforge-0.4.1 → screenforge-0.5.0}/cli/modes/init.py +0 -0
  25. {screenforge-0.4.1 → screenforge-0.5.0}/cli/modes/plan.py +0 -0
  26. {screenforge-0.4.1 → screenforge-0.5.0}/cli/reporter.py +0 -0
  27. {screenforge-0.4.1 → screenforge-0.5.0}/cli/session.py +0 -0
  28. {screenforge-0.4.1 → screenforge-0.5.0}/cli/shared.py +0 -0
  29. {screenforge-0.4.1 → screenforge-0.5.0}/cli/shorthand.py +0 -0
  30. {screenforge-0.4.1 → screenforge-0.5.0}/cli/tool_protocol_handlers.py +0 -0
  31. {screenforge-0.4.1 → screenforge-0.5.0}/common/__init__.py +0 -0
  32. {screenforge-0.4.1 → screenforge-0.5.0}/common/adapters/__init__.py +0 -0
  33. {screenforge-0.4.1 → screenforge-0.5.0}/common/adapters/android_adapter.py +0 -0
  34. {screenforge-0.4.1 → screenforge-0.5.0}/common/adapters/base_adapter.py +0 -0
  35. {screenforge-0.4.1 → screenforge-0.5.0}/common/adapters/ios_adapter.py +0 -0
  36. {screenforge-0.4.1 → screenforge-0.5.0}/common/adapters/web_adapter.py +0 -0
  37. {screenforge-0.4.1 → screenforge-0.5.0}/common/ai.py +0 -0
  38. {screenforge-0.4.1 → screenforge-0.5.0}/common/ai_autonomous.py +0 -0
  39. {screenforge-0.4.1 → screenforge-0.5.0}/common/ai_heal.py +0 -0
  40. {screenforge-0.4.1 → screenforge-0.5.0}/common/cache/__init__.py +0 -0
  41. {screenforge-0.4.1 → screenforge-0.5.0}/common/cache/cache_hash.py +0 -0
  42. {screenforge-0.4.1 → screenforge-0.5.0}/common/cache/cache_manager.py +0 -0
  43. {screenforge-0.4.1 → screenforge-0.5.0}/common/cache/cache_stats.py +0 -0
  44. {screenforge-0.4.1 → screenforge-0.5.0}/common/cache/cache_storage.py +0 -0
  45. {screenforge-0.4.1 → screenforge-0.5.0}/common/cache/embedding_loader.py +0 -0
  46. {screenforge-0.4.1 → screenforge-0.5.0}/common/capabilities.py +0 -0
  47. {screenforge-0.4.1 → screenforge-0.5.0}/common/case_memory.py +0 -0
  48. {screenforge-0.4.1 → screenforge-0.5.0}/common/error_codes.py +0 -0
  49. {screenforge-0.4.1 → screenforge-0.5.0}/common/exceptions.py +0 -0
  50. {screenforge-0.4.1 → screenforge-0.5.0}/common/executor.py +0 -0
  51. {screenforge-0.4.1 → screenforge-0.5.0}/common/failure_diagnosis.py +0 -0
  52. {screenforge-0.4.1 → screenforge-0.5.0}/common/history_manager.py +0 -0
  53. {screenforge-0.4.1 → screenforge-0.5.0}/common/logs.py +0 -0
  54. {screenforge-0.4.1 → screenforge-0.5.0}/common/mcp_server.py +0 -0
  55. {screenforge-0.4.1 → screenforge-0.5.0}/common/preflight.py +0 -0
  56. {screenforge-0.4.1 → screenforge-0.5.0}/common/progress.py +0 -0
  57. {screenforge-0.4.1 → screenforge-0.5.0}/common/run_reporter.py +0 -0
  58. {screenforge-0.4.1 → screenforge-0.5.0}/common/run_resume.py +0 -0
  59. {screenforge-0.4.1 → screenforge-0.5.0}/common/runtime_modes.py +0 -0
  60. {screenforge-0.4.1 → screenforge-0.5.0}/common/tool_protocol.py +0 -0
  61. {screenforge-0.4.1 → screenforge-0.5.0}/common/visual_fallback.py +0 -0
  62. {screenforge-0.4.1 → screenforge-0.5.0}/common/workflow_schema.py +0 -0
  63. {screenforge-0.4.1 → screenforge-0.5.0}/config/__init__.py +0 -0
  64. {screenforge-0.4.1 → screenforge-0.5.0}/config/config.py +0 -0
  65. {screenforge-0.4.1 → screenforge-0.5.0}/config/env_loader.py +0 -0
  66. {screenforge-0.4.1 → screenforge-0.5.0}/screenforge.egg-info/dependency_links.txt +0 -0
  67. {screenforge-0.4.1 → screenforge-0.5.0}/screenforge.egg-info/entry_points.txt +0 -0
  68. {screenforge-0.4.1 → screenforge-0.5.0}/screenforge.egg-info/requires.txt +0 -0
  69. {screenforge-0.4.1 → screenforge-0.5.0}/screenforge.egg-info/top_level.txt +0 -0
  70. {screenforge-0.4.1 → screenforge-0.5.0}/setup.cfg +0 -0
  71. {screenforge-0.4.1 → screenforge-0.5.0}/tests/test_ai_autonomous.py +0 -0
  72. {screenforge-0.4.1 → screenforge-0.5.0}/tests/test_ai_brain.py +0 -0
  73. {screenforge-0.4.1 → screenforge-0.5.0}/tests/test_ai_heal.py +0 -0
  74. {screenforge-0.4.1 → screenforge-0.5.0}/tests/test_android_smoke_live.py +0 -0
  75. {screenforge-0.4.1 → screenforge-0.5.0}/tests/test_cache_manager.py +0 -0
  76. {screenforge-0.4.1 → screenforge-0.5.0}/tests/test_capabilities.py +0 -0
  77. {screenforge-0.4.1 → screenforge-0.5.0}/tests/test_cli_action_json.py +0 -0
  78. {screenforge-0.4.1 → screenforge-0.5.0}/tests/test_codegen_quality.py +0 -0
  79. {screenforge-0.4.1 → screenforge-0.5.0}/tests/test_dispatch.py +0 -0
  80. {screenforge-0.4.1 → screenforge-0.5.0}/tests/test_doctor_orphan_browser.py +0 -0
  81. {screenforge-0.4.1 → screenforge-0.5.0}/tests/test_error_codes.py +0 -0
  82. {screenforge-0.4.1 → screenforge-0.5.0}/tests/test_executor.py +0 -0
  83. {screenforge-0.4.1 → screenforge-0.5.0}/tests/test_failure_diagnosis.py +0 -0
  84. {screenforge-0.4.1 → screenforge-0.5.0}/tests/test_interaction_actions.py +0 -0
  85. {screenforge-0.4.1 → screenforge-0.5.0}/tests/test_ios_smoke_live.py +0 -0
  86. {screenforge-0.4.1 → screenforge-0.5.0}/tests/test_mcp_ref_cache.py +0 -0
  87. {screenforge-0.4.1 → screenforge-0.5.0}/tests/test_ml_optional.py +0 -0
  88. {screenforge-0.4.1 → screenforge-0.5.0}/tests/test_run_reporter.py +0 -0
  89. {screenforge-0.4.1 → screenforge-0.5.0}/tests/test_run_resume.py +0 -0
  90. {screenforge-0.4.1 → screenforge-0.5.0}/tests/test_runtime_modes.py +0 -0
  91. {screenforge-0.4.1 → screenforge-0.5.0}/tests/test_screenshot_annotator.py +0 -0
  92. {screenforge-0.4.1 → screenforge-0.5.0}/tests/test_shorthand.py +0 -0
  93. {screenforge-0.4.1 → screenforge-0.5.0}/tests/test_tool_protocol_diagnosis.py +0 -0
  94. {screenforge-0.4.1 → screenforge-0.5.0}/tests/test_utils_ios.py +0 -0
  95. {screenforge-0.4.1 → screenforge-0.5.0}/tests/test_utils_web.py +0 -0
  96. {screenforge-0.4.1 → screenforge-0.5.0}/tests/test_utils_xml.py +0 -0
  97. {screenforge-0.4.1 → screenforge-0.5.0}/tests/test_visual_fallback.py +0 -0
  98. {screenforge-0.4.1 → screenforge-0.5.0}/tests/test_web_adapter.py +0 -0
  99. {screenforge-0.4.1 → screenforge-0.5.0}/tests/test_web_dom_complex_live.py +0 -0
  100. {screenforge-0.4.1 → screenforge-0.5.0}/tests/test_web_smoke_live.py +0 -0
  101. {screenforge-0.4.1 → screenforge-0.5.0}/utils/__init__.py +0 -0
  102. {screenforge-0.4.1 → screenforge-0.5.0}/utils/screenshot_annotator.py +0 -0
  103. {screenforge-0.4.1 → screenforge-0.5.0}/utils/utils_ios.py +0 -0
  104. {screenforge-0.4.1 → screenforge-0.5.0}/utils/utils_web.py +0 -0
  105. {screenforge-0.4.1 → screenforge-0.5.0}/utils/utils_xml.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: screenforge
3
- Version: 0.4.1
3
+ Version: 0.5.0
4
4
  Summary: AI-driven cross-platform UI automation engine with test script generation
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/jhinzzz/ScreenForge
@@ -113,6 +113,7 @@ Each step: **inspect → decide → act → verify**. The AI decides, ScreenForg
113
113
  - **Visual fallback**: When DOM can't locate elements (Canvas, games), VLM parses screenshots
114
114
  - **MCP server**: Any MCP-compatible Agent can drive ScreenForge natively
115
115
  - **Structured output**: JSON Lines events + `report/runs/<id>/` artifacts for CI integration
116
+ - **Live Mirror playground**: Watch the generated pytest code grow line-by-line beside a live screenshot as the test runs — `screenforge --playground`. See the [Playground Guide](https://github.com/jhinzzz/ScreenForge/blob/main/docs/playground-guide.md)
116
117
 
117
118
  ## Agent Integration (Claude Code / Cursor / Codex)
118
119
 
@@ -205,6 +206,7 @@ If ScreenForge generates tests for your project, add this badge to your README:
205
206
  | [MCP Setup (3 min)](https://github.com/jhinzzz/ScreenForge/blob/main/docs/mcp-setup.md) | Connect to Claude Desktop / Cursor / Cline / Claude Code |
206
207
  | [Agent Guide](https://github.com/jhinzzz/ScreenForge/blob/main/docs/agent_guide.md) | Integration protocol for AI Agents |
207
208
  | [Capability Matrix](https://github.com/jhinzzz/ScreenForge/blob/main/docs/capability-matrix.md) | Supported platforms, actions, and locators |
209
+ | [Playground Guide](https://github.com/jhinzzz/ScreenForge/blob/main/docs/playground-guide.md) | Live Mirror — watch code + screenshots grow as the test runs |
208
210
  | [Workflow Examples](https://github.com/jhinzzz/ScreenForge/tree/main/docs/workflows) | YAML workflow templates |
209
211
  | [CHANGELOG](https://github.com/jhinzzz/ScreenForge/blob/main/CHANGELOG.md) | Version history |
210
212
 
@@ -68,6 +68,7 @@ Each step: **inspect → decide → act → verify**. The AI decides, ScreenForg
68
68
  - **Visual fallback**: When DOM can't locate elements (Canvas, games), VLM parses screenshots
69
69
  - **MCP server**: Any MCP-compatible Agent can drive ScreenForge natively
70
70
  - **Structured output**: JSON Lines events + `report/runs/<id>/` artifacts for CI integration
71
+ - **Live Mirror playground**: Watch the generated pytest code grow line-by-line beside a live screenshot as the test runs — `screenforge --playground`. See the [Playground Guide](https://github.com/jhinzzz/ScreenForge/blob/main/docs/playground-guide.md)
71
72
 
72
73
  ## Agent Integration (Claude Code / Cursor / Codex)
73
74
 
@@ -160,6 +161,7 @@ If ScreenForge generates tests for your project, add this badge to your README:
160
161
  | [MCP Setup (3 min)](https://github.com/jhinzzz/ScreenForge/blob/main/docs/mcp-setup.md) | Connect to Claude Desktop / Cursor / Cline / Claude Code |
161
162
  | [Agent Guide](https://github.com/jhinzzz/ScreenForge/blob/main/docs/agent_guide.md) | Integration protocol for AI Agents |
162
163
  | [Capability Matrix](https://github.com/jhinzzz/ScreenForge/blob/main/docs/capability-matrix.md) | Supported platforms, actions, and locators |
164
+ | [Playground Guide](https://github.com/jhinzzz/ScreenForge/blob/main/docs/playground-guide.md) | Live Mirror — watch code + screenshots grow as the test runs |
163
165
  | [Workflow Examples](https://github.com/jhinzzz/ScreenForge/tree/main/docs/workflows) | YAML workflow templates |
164
166
  | [CHANGELOG](https://github.com/jhinzzz/ScreenForge/blob/main/CHANGELOG.md) | Version history |
165
167
 
@@ -0,0 +1 @@
1
+ __version__ = "0.5.0"
@@ -5,6 +5,7 @@ import os
5
5
  import sys
6
6
 
7
7
  import cli.shared as _shared
8
+ from cli.playground_sink import build_sink_from_args, maybe_push_step
8
9
  from cli.reporter import (
9
10
  _apply_resume_summary,
10
11
  _build_action_summary,
@@ -206,6 +207,18 @@ def run_action_default_mode(
206
207
 
207
208
  history_manager.add_step(result["code_lines"], result["action_description"])
208
209
  save_to_disk(output_script_path, history_manager.get_current_file_content())
210
+ # ★ Live-mirror bypass (opt-in --playground-sink). join_on_exit=True: a bare
211
+ # --action exits right after, so wait briefly for the last frame to land.
212
+ maybe_push_step(
213
+ build_sink_from_args(args, join_on_exit=True),
214
+ args=args,
215
+ reporter=reporter,
216
+ adapter=adapter,
217
+ action_data=action_data,
218
+ result=result,
219
+ step_index=None, # resolver picks: session counter, or 1 for a bare action
220
+ file_path=output_script_path, # normalized to abs path inside build_step_event
221
+ )
209
222
  reporter.emit_event(
210
223
  "action_executed",
211
224
  step=1,
@@ -4,6 +4,7 @@ from pathlib import Path
4
4
 
5
5
  import cli.shared as _shared
6
6
  from cli.modes.dry_run import _build_resolution_hint, _preview_action_resolution
7
+ from cli.playground_sink import build_sink_from_args, maybe_push_step
7
8
  from cli.reporter import (
8
9
  _apply_resume_summary,
9
10
  _build_reporter,
@@ -285,6 +286,9 @@ def run_workflow_default_mode(
285
286
  history_manager = _shared.StepHistoryManager(initial_content=header)
286
287
  save_to_disk(output_script_path, header)
287
288
  executor = _shared.UIExecutor(device, platform=args.platform)
289
+ # Workflow is one process with many steps (the main use case for live mirror).
290
+ # Build the sink once; join_on_exit=False — the process lives across all steps.
291
+ sink = build_sink_from_args(args, join_on_exit=False)
288
292
 
289
293
  executed_steps = 0
290
294
  for index, step in enumerate(workflow.steps, start=1):
@@ -315,6 +319,17 @@ def run_workflow_default_mode(
315
319
  result["code_lines"], result["action_description"]
316
320
  )
317
321
  save_to_disk(output_script_path, history_manager.get_current_file_content())
322
+ # ★ Live-mirror bypass: push this step with its loop counter as step_index.
323
+ maybe_push_step(
324
+ sink,
325
+ args=args,
326
+ reporter=reporter,
327
+ adapter=adapter,
328
+ action_data=action_data,
329
+ result=result,
330
+ step_index=index,
331
+ file_path=output_script_path, # normalized to abs path inside build_step_event
332
+ )
318
333
  reporter.emit_event(
319
334
  "action_executed",
320
335
  step=index,
@@ -151,6 +151,17 @@ def build_parser() -> argparse.ArgumentParser:
151
151
  default=7860,
152
152
  help="Playground server port (default: 7860)",
153
153
  )
154
+ parser.add_argument(
155
+ "--playground-sink",
156
+ action="store_true",
157
+ help="Push each step's code + screenshot to a running playground (opt-in; off = zero cost)",
158
+ )
159
+ parser.add_argument(
160
+ "--playground-url",
161
+ type=str,
162
+ default="http://127.0.0.1:7860",
163
+ help="Playground base URL for --playground-sink (default: http://127.0.0.1:7860)",
164
+ )
154
165
  parser.add_argument(
155
166
  "--device-url",
156
167
  type=str,
@@ -270,7 +281,10 @@ def validate_cli_args(args: argparse.Namespace) -> None:
270
281
  raise ValueError("--action cannot be combined with --goal or --workflow")
271
282
  has_demo = bool(getattr(args, "demo", False))
272
283
  has_init = bool(getattr(args, "init", False))
273
- if has_demo or has_init:
284
+ # --playground starts a standalone server (dispatch.py handles it after this
285
+ # validation, exactly like --init/--demo) — it needs no goal/workflow/action.
286
+ has_playground = bool(getattr(args, "playground", False))
287
+ if has_demo or has_init or has_playground:
274
288
  return
275
289
  has_session_end = bool(str(getattr(args, "session_end", "")).strip())
276
290
  if has_session_end:
@@ -0,0 +1,202 @@
1
+ """Fire-and-forget visualization sink: short-lived action process → resident playground.
2
+
3
+ Red line (G5): any network error is swallowed silently. A sink push MUST NEVER
4
+ slow down the action or change its exit code — the 0/1 exit code is a contract to
5
+ the agent (see CLAUDE.md). The sink is a bypass observer hung after save_to_disk;
6
+ it does not touch execute_and_record, codegen, or disk persistence.
7
+ """
8
+
9
+ import base64
10
+ import os
11
+ import threading
12
+
13
+ import requests # already in requirements.txt (requests==2.32.5) — zero new deps
14
+ from loguru import logger as log
15
+ from pydantic import BaseModel, Field
16
+
17
+ from cli.session import load_session
18
+
19
+ DEFAULT_PLAYGROUND_URL = "http://127.0.0.1:7860"
20
+
21
+ # Latency ceiling on the contract-protected single-step --action path (red line #1:
22
+ # "never slow the action down"). A reachable-but-slow playground must not tax the
23
+ # action beyond this documented budget. (connect, read) is split so a hung peer
24
+ # can't stall on connect; _JOIN_TIMEOUT is the hard cap the single-step process
25
+ # waits for the last frame to land before sys.exit — kept ≤ read+ε and well under
26
+ # human-perceptible. Worst added latency on --action ≈ _JOIN_TIMEOUT.
27
+ _POST_TIMEOUT = (0.2, 0.25) # (connect, read) seconds
28
+ _JOIN_TIMEOUT = 0.3 # seconds; single-step last-frame grace
29
+
30
+
31
+ class PlaygroundStepEvent(BaseModel):
32
+ """One step pushed to the playground. Shape == the frontend SSE `step` contract."""
33
+
34
+ run_id: str
35
+ step_index: int # ⭐ time-travel seed: data accumulates/replays by this index
36
+ code_lines: list[str] = Field(default_factory=list)
37
+ action_description: str = ""
38
+ action: str = ""
39
+ locator_type: str = ""
40
+ locator_value: str = ""
41
+ extra_value: str = ""
42
+ success: bool = True
43
+ screenshot_b64: str = "" # empty = no screenshot this step (degrade, never crash)
44
+ file_path: str = "" # abs path of the generated test file (for "open in IDE")
45
+
46
+
47
+ class PlaygroundSink:
48
+ """Pushes each step to a running playground, best-effort.
49
+
50
+ Disabled by default: enabled=False means zero cost — no HTTP, no thread, and
51
+ (at the call sites) take_screenshot is never even invoked.
52
+ """
53
+
54
+ def __init__(
55
+ self,
56
+ base_url: str = DEFAULT_PLAYGROUND_URL,
57
+ enabled: bool = False,
58
+ join_on_exit: bool = False,
59
+ ):
60
+ self.base_url = base_url.rstrip("/")
61
+ self.enabled = enabled
62
+ # Single-step --action exits the process right after push_step returns;
63
+ # join a short window so the daemon thread's last frame can land (§6 单步收尾).
64
+ self._join_on_exit = join_on_exit
65
+
66
+ def push_step(self, event: PlaygroundStepEvent) -> None:
67
+ """Best-effort push. Hands off to a daemon thread; the caller returns at
68
+ once (arch#3: never block the action hot path)."""
69
+ if not self.enabled:
70
+ return
71
+ t = threading.Thread(target=self._post, args=(event,), daemon=True)
72
+ t.start()
73
+ if self._join_on_exit:
74
+ # Single-step --action exits right after this returns; wait a bounded
75
+ # grace (≤ _JOIN_TIMEOUT) for the last frame to land. This is the hard
76
+ # ceiling on added latency to the contract path — never grow it past a
77
+ # human-imperceptible budget (HIGH-1 from review: 0.6s was too generous).
78
+ t.join(timeout=_JOIN_TIMEOUT)
79
+
80
+ def _post(self, event: PlaygroundStepEvent) -> None:
81
+ try:
82
+ requests.post(
83
+ f"{self.base_url}/api/step",
84
+ json=event.model_dump(),
85
+ timeout=_POST_TIMEOUT, # (connect, read) split: a hung playground can't stall us
86
+ )
87
+ except Exception as e: # ConnectionError / Timeout / anything — swallow (G5)
88
+ log.debug(f"[playground-sink] skip (playground unreachable): {e}")
89
+
90
+ @staticmethod
91
+ def encode_screenshot(adapter) -> str:
92
+ """Cross-platform: take_screenshot() -> bytes → base64. Can't grab → '' (degrade).
93
+
94
+ Platform-agnostic on purpose: all three adapters expose take_screenshot()
95
+ (base_adapter.py:17 abstract), so no per-platform branching is needed here.
96
+ """
97
+ try:
98
+ png = adapter.take_screenshot()
99
+ return base64.b64encode(png).decode() if png else ""
100
+ except Exception as e:
101
+ log.debug(f"[playground-sink] screenshot skip: {e}")
102
+ return ""
103
+
104
+
105
+ def build_step_event(
106
+ *,
107
+ run_key: str,
108
+ step_index: int,
109
+ action_data: dict,
110
+ result: dict,
111
+ screenshot_b64: str,
112
+ file_path: str = "",
113
+ ) -> PlaygroundStepEvent:
114
+ """MANDATORY single construction point for every step event (code#4).
115
+
116
+ All three entry points (action / workflow / main) build events ONLY through
117
+ here. Adding a field later (e.g. a seed timestamp) is then one edit, not three
118
+ — preventing the P9-style schema split where one call site silently drifts.
119
+
120
+ file_path is normalized to an absolute path HERE (one idiom, one place) rather
121
+ than at each call site, so it happens inside maybe_push_step's G5 try/except.
122
+ Empty stays empty — abspath('') would wrongly yield the cwd, and the frontend
123
+ treats '' as "no openable file" (disables the IDE button), so guard it.
124
+ """
125
+ return PlaygroundStepEvent(
126
+ run_id=run_key,
127
+ step_index=step_index,
128
+ code_lines=result.get("code_lines", []) or [],
129
+ action_description=result.get("action_description", ""),
130
+ action=action_data.get("action", ""),
131
+ locator_type=action_data.get("locator_type", ""),
132
+ locator_value=action_data.get("locator_value", ""),
133
+ extra_value=action_data.get("extra_value", ""),
134
+ success=bool(result.get("success", True)),
135
+ screenshot_b64=screenshot_b64,
136
+ file_path=os.path.abspath(file_path) if file_path else "",
137
+ )
138
+
139
+
140
+ def build_sink_from_args(args, *, join_on_exit: bool = False) -> "PlaygroundSink":
141
+ """Construct a sink from parsed CLI args. Absent flags → disabled (zero cost)."""
142
+ return PlaygroundSink(
143
+ base_url=getattr(args, "playground_url", "") or DEFAULT_PLAYGROUND_URL,
144
+ enabled=bool(getattr(args, "playground_sink", False)),
145
+ join_on_exit=join_on_exit,
146
+ )
147
+
148
+
149
+ def maybe_push_step(
150
+ sink: "PlaygroundSink",
151
+ *,
152
+ args,
153
+ reporter,
154
+ adapter,
155
+ action_data: dict,
156
+ result: dict,
157
+ step_index: int | None = None,
158
+ file_path: str = "",
159
+ ) -> None:
160
+ """The ONE guarded entry point every call site uses (action / workflow / main).
161
+
162
+ Disabled-fast: returns before touching the adapter, so take_screenshot is
163
+ never called and there is zero device I/O or network on the hot path when the
164
+ sink is off. Wrapped in a blanket try/except as a belt-and-suspenders G5
165
+ guard — the bypass observer must never break the action it observes.
166
+ """
167
+ if not sink.enabled:
168
+ return
169
+ try:
170
+ run_key, resolved_index = resolve_playground_run_key(args, reporter)
171
+ event = build_step_event(
172
+ run_key=run_key,
173
+ step_index=step_index if step_index is not None else resolved_index,
174
+ action_data=action_data,
175
+ result=result,
176
+ screenshot_b64=PlaygroundSink.encode_screenshot(adapter),
177
+ file_path=file_path,
178
+ )
179
+ sink.push_step(event)
180
+ except Exception as e: # never let visualization break the observed action
181
+ log.debug(f"[playground-sink] push skipped: {e}")
182
+
183
+
184
+ def resolve_playground_run_key(args, reporter) -> tuple[str, int]:
185
+ """Return (run_key, step_index) — the cross-process-stable playground timeline key.
186
+
187
+ Root cause (arch#1): run_reporter.py mints run_id as `timestamp_uuid`, unique
188
+ per short-lived process. In agent mode each --action is its own process, so
189
+ using reporter.run_id directly would shatter a 5-step flow into 5 single-step
190
+ buckets and the seed's timeline would be born broken.
191
+
192
+ --session-id present → use session_id as the key (one session = one timeline);
193
+ step_index comes from the session's persisted 'steps' counter (cli/session.py),
194
+ which dispatch.py increments AFTER each successful step, so steps+1 is the
195
+ 1-based index of the step about to be pushed.
196
+ No session → a bare --action is inherently single-step: reporter.run_id, index 1.
197
+ """
198
+ session_id = getattr(args, "session_id", "") or getattr(args, "session_end", "")
199
+ if session_id:
200
+ session = load_session(session_id)
201
+ return session_id, (session.get("steps", 0) + 1 if session else 1)
202
+ return reporter.run_id, 1
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "screenforge"
3
- version = "0.4.1"
3
+ version = "0.5.0"
4
4
  description = "AI-driven cross-platform UI automation engine with test script generation"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: screenforge
3
- Version: 0.4.1
3
+ Version: 0.5.0
4
4
  Summary: AI-driven cross-platform UI automation engine with test script generation
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/jhinzzz/ScreenForge
@@ -113,6 +113,7 @@ Each step: **inspect → decide → act → verify**. The AI decides, ScreenForg
113
113
  - **Visual fallback**: When DOM can't locate elements (Canvas, games), VLM parses screenshots
114
114
  - **MCP server**: Any MCP-compatible Agent can drive ScreenForge natively
115
115
  - **Structured output**: JSON Lines events + `report/runs/<id>/` artifacts for CI integration
116
+ - **Live Mirror playground**: Watch the generated pytest code grow line-by-line beside a live screenshot as the test runs — `screenforge --playground`. See the [Playground Guide](https://github.com/jhinzzz/ScreenForge/blob/main/docs/playground-guide.md)
116
117
 
117
118
  ## Agent Integration (Claude Code / Cursor / Codex)
118
119
 
@@ -205,6 +206,7 @@ If ScreenForge generates tests for your project, add this badge to your README:
205
206
  | [MCP Setup (3 min)](https://github.com/jhinzzz/ScreenForge/blob/main/docs/mcp-setup.md) | Connect to Claude Desktop / Cursor / Cline / Claude Code |
206
207
  | [Agent Guide](https://github.com/jhinzzz/ScreenForge/blob/main/docs/agent_guide.md) | Integration protocol for AI Agents |
207
208
  | [Capability Matrix](https://github.com/jhinzzz/ScreenForge/blob/main/docs/capability-matrix.md) | Supported platforms, actions, and locators |
209
+ | [Playground Guide](https://github.com/jhinzzz/ScreenForge/blob/main/docs/playground-guide.md) | Live Mirror — watch code + screenshots grow as the test runs |
208
210
  | [Workflow Examples](https://github.com/jhinzzz/ScreenForge/tree/main/docs/workflows) | YAML workflow templates |
209
211
  | [CHANGELOG](https://github.com/jhinzzz/ScreenForge/blob/main/CHANGELOG.md) | Version history |
210
212
 
@@ -6,6 +6,7 @@ cli/_version.py
6
6
  cli/dispatch.py
7
7
  cli/doctor.py
8
8
  cli/parser.py
9
+ cli/playground_sink.py
9
10
  cli/reporter.py
10
11
  cli/session.py
11
12
  cli/shared.py
@@ -78,6 +79,9 @@ tests/test_ios_smoke_live.py
78
79
  tests/test_mcp_ref_cache.py
79
80
  tests/test_ml_optional.py
80
81
  tests/test_parser.py
82
+ tests/test_playground_app.py
83
+ tests/test_playground_sink.py
84
+ tests/test_playground_sink_integration.py
81
85
  tests/test_run_reporter.py
82
86
  tests/test_run_resume.py
83
87
  tests/test_runtime_modes.py
@@ -144,3 +144,15 @@ class TestValidateCliArgs:
144
144
  def test_demo_valid(self):
145
145
  args = self._make_args(goal="", demo=True)
146
146
  validate_cli_args(args)
147
+
148
+ def test_playground_alone_valid(self):
149
+ # Regression: --playground starts a standalone server (dispatch handles it
150
+ # AFTER this validation, like --init/--demo) and needs no goal/action.
151
+ # Before the fix it raised "Must provide --goal/--workflow/--action",
152
+ # making the live-mirror entry point impossible to launch from the CLI.
153
+ args = self._make_args(goal="", playground=True)
154
+ validate_cli_args(args)
155
+
156
+ def test_playground_with_port_valid(self):
157
+ args = self._make_args(goal="", playground=True, playground_port=8000)
158
+ validate_cli_args(args)