screenforge 0.4.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.
- cli/__init__.py +0 -0
- cli/_version.py +1 -0
- cli/dispatch.py +266 -0
- cli/doctor.py +487 -0
- cli/modes/__init__.py +0 -0
- cli/modes/action.py +262 -0
- cli/modes/default.py +248 -0
- cli/modes/demo.py +162 -0
- cli/modes/dry_run.py +237 -0
- cli/modes/init.py +133 -0
- cli/modes/plan.py +148 -0
- cli/modes/workflow.py +354 -0
- cli/parser.py +305 -0
- cli/reporter.py +207 -0
- cli/session.py +146 -0
- cli/shared.py +427 -0
- cli/shorthand.py +90 -0
- cli/tool_protocol_handlers.py +446 -0
- common/__init__.py +0 -0
- common/adapters/__init__.py +21 -0
- common/adapters/android_adapter.py +273 -0
- common/adapters/base_adapter.py +24 -0
- common/adapters/ios_adapter.py +278 -0
- common/adapters/web_adapter.py +271 -0
- common/ai.py +277 -0
- common/ai_autonomous.py +273 -0
- common/ai_heal.py +222 -0
- common/cache/__init__.py +15 -0
- common/cache/cache_hash.py +57 -0
- common/cache/cache_manager.py +300 -0
- common/cache/cache_stats.py +133 -0
- common/cache/cache_storage.py +79 -0
- common/cache/embedding_loader.py +150 -0
- common/capabilities.py +121 -0
- common/case_memory.py +327 -0
- common/error_codes.py +61 -0
- common/exceptions.py +18 -0
- common/executor.py +1504 -0
- common/failure_diagnosis.py +138 -0
- common/history_manager.py +75 -0
- common/logs.py +168 -0
- common/mcp_server.py +467 -0
- common/preflight.py +496 -0
- common/progress.py +37 -0
- common/run_reporter.py +415 -0
- common/run_resume.py +149 -0
- common/runtime_modes.py +35 -0
- common/tool_protocol.py +196 -0
- common/visual_fallback.py +71 -0
- common/workflow_schema.py +150 -0
- config/__init__.py +0 -0
- config/config.py +167 -0
- config/env_loader.py +76 -0
- screenforge-0.4.0.dist-info/METADATA +43 -0
- screenforge-0.4.0.dist-info/RECORD +64 -0
- screenforge-0.4.0.dist-info/WHEEL +5 -0
- screenforge-0.4.0.dist-info/entry_points.txt +2 -0
- screenforge-0.4.0.dist-info/licenses/LICENSE +21 -0
- screenforge-0.4.0.dist-info/top_level.txt +4 -0
- utils/__init__.py +0 -0
- utils/screenshot_annotator.py +60 -0
- utils/utils_ios.py +195 -0
- utils/utils_web.py +304 -0
- utils/utils_xml.py +218 -0
common/preflight.py
ADDED
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
import importlib
|
|
2
|
+
import json
|
|
3
|
+
import shutil
|
|
4
|
+
import socket
|
|
5
|
+
import subprocess
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Dict, List
|
|
8
|
+
from urllib.parse import urlparse
|
|
9
|
+
from urllib.request import urlopen
|
|
10
|
+
|
|
11
|
+
import config.config as config
|
|
12
|
+
from utils.utils_web import normalize_loopback_url
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _resolve_venv_dir(project_root: Path) -> Path:
|
|
16
|
+
project_root = Path(project_root).resolve()
|
|
17
|
+
for candidate in [project_root, *project_root.parents]:
|
|
18
|
+
venv_dir = candidate / ".venv"
|
|
19
|
+
if venv_dir.exists():
|
|
20
|
+
return venv_dir
|
|
21
|
+
return project_root / ".venv"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _iter_venv_entrypoints(bin_dir: Path):
|
|
25
|
+
if not bin_dir.exists():
|
|
26
|
+
return
|
|
27
|
+
|
|
28
|
+
for path in sorted(bin_dir.iterdir()):
|
|
29
|
+
if not path.is_file():
|
|
30
|
+
continue
|
|
31
|
+
try:
|
|
32
|
+
with path.open("r", encoding="utf-8") as f:
|
|
33
|
+
first_line = f.readline().strip()
|
|
34
|
+
except (OSError, UnicodeDecodeError):
|
|
35
|
+
continue
|
|
36
|
+
if first_line.startswith("#!") and ".venv/bin/python" in first_line:
|
|
37
|
+
yield path, first_line
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def check_virtualenv_consistency(project_root: Path) -> Dict[str, object]:
|
|
41
|
+
project_root = Path(project_root)
|
|
42
|
+
venv_dir = _resolve_venv_dir(project_root)
|
|
43
|
+
issues: List[str] = []
|
|
44
|
+
checked_scripts: List[str] = []
|
|
45
|
+
|
|
46
|
+
if not venv_dir.exists():
|
|
47
|
+
return {
|
|
48
|
+
"name": "venv_consistency",
|
|
49
|
+
"ok": True,
|
|
50
|
+
"venv_dir": str(venv_dir),
|
|
51
|
+
"issues": issues,
|
|
52
|
+
"checked_scripts": checked_scripts,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
pyvenv_cfg = venv_dir / "pyvenv.cfg"
|
|
56
|
+
if pyvenv_cfg.exists():
|
|
57
|
+
for line in pyvenv_cfg.read_text(encoding="utf-8").splitlines():
|
|
58
|
+
if line.startswith("command = ") and str(venv_dir) not in line and ".venv" in line:
|
|
59
|
+
issues.append(f"pyvenv.cfg command 指向了其他环境: {line.split('=', 1)[1].strip()}")
|
|
60
|
+
|
|
61
|
+
for path, shebang in _iter_venv_entrypoints(venv_dir / "bin"):
|
|
62
|
+
checked_scripts.append(path.name)
|
|
63
|
+
if str(venv_dir) not in shebang:
|
|
64
|
+
issues.append(f"{path.name} shebang 指向了其他环境: {shebang[2:]}")
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
"name": "venv_consistency",
|
|
68
|
+
"ok": not issues,
|
|
69
|
+
"venv_dir": str(venv_dir),
|
|
70
|
+
"issues": issues,
|
|
71
|
+
"checked_scripts": checked_scripts,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def repair_virtualenv_consistency(project_root: Path) -> Dict[str, object]:
|
|
76
|
+
project_root = Path(project_root)
|
|
77
|
+
venv_dir = _resolve_venv_dir(project_root)
|
|
78
|
+
bin_dir = venv_dir / "bin"
|
|
79
|
+
expected_python = bin_dir / "python3.13"
|
|
80
|
+
if not expected_python.exists():
|
|
81
|
+
expected_python = bin_dir / "python"
|
|
82
|
+
|
|
83
|
+
updated_scripts: List[str] = []
|
|
84
|
+
if bin_dir.exists():
|
|
85
|
+
for path, shebang in _iter_venv_entrypoints(bin_dir):
|
|
86
|
+
if str(venv_dir) in shebang:
|
|
87
|
+
continue
|
|
88
|
+
|
|
89
|
+
original = path.read_text(encoding="utf-8")
|
|
90
|
+
original_lines = original.splitlines()
|
|
91
|
+
if not original_lines:
|
|
92
|
+
continue
|
|
93
|
+
original_lines[0] = f"#!{expected_python}"
|
|
94
|
+
new_text = "\n".join(original_lines)
|
|
95
|
+
if original.endswith("\n"):
|
|
96
|
+
new_text += "\n"
|
|
97
|
+
path.write_text(new_text, encoding="utf-8")
|
|
98
|
+
updated_scripts.append(path.name)
|
|
99
|
+
|
|
100
|
+
cfg_updated = False
|
|
101
|
+
pyvenv_cfg = venv_dir / "pyvenv.cfg"
|
|
102
|
+
if pyvenv_cfg.exists():
|
|
103
|
+
new_lines = []
|
|
104
|
+
for line in pyvenv_cfg.read_text(encoding="utf-8").splitlines():
|
|
105
|
+
if line.startswith("command = ") and str(venv_dir) not in line and " -m venv " in line:
|
|
106
|
+
command_prefix = line.split(" -m venv ", 1)[0].split("=", 1)[1].strip()
|
|
107
|
+
line = f"command = {command_prefix} -m venv {venv_dir}"
|
|
108
|
+
cfg_updated = True
|
|
109
|
+
new_lines.append(line)
|
|
110
|
+
pyvenv_cfg.write_text("\n".join(new_lines) + "\n", encoding="utf-8")
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
"venv_dir": str(venv_dir),
|
|
114
|
+
"updated_scripts": updated_scripts,
|
|
115
|
+
"updated_pyvenv_cfg": cfg_updated,
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def check_required_config() -> Dict[str, object]:
|
|
120
|
+
errors: List[str] = []
|
|
121
|
+
|
|
122
|
+
if not config.OPENAI_API_KEY:
|
|
123
|
+
errors.append("OPENAI_API_KEY 未配置")
|
|
124
|
+
if config.DEFAULT_TIMEOUT <= 0:
|
|
125
|
+
errors.append(f"DEFAULT_TIMEOUT 必须大于 0,当前值: {config.DEFAULT_TIMEOUT}")
|
|
126
|
+
if not 0 <= config.CACHE_SIMILARITY_THRESHOLD <= 1:
|
|
127
|
+
errors.append(
|
|
128
|
+
f"CACHE_SIMILARITY_THRESHOLD 必须在 0-1 之间,当前值: {config.CACHE_SIMILARITY_THRESHOLD}"
|
|
129
|
+
)
|
|
130
|
+
if not 0 <= config.CACHE_EXACT_MATCH_THRESHOLD <= 1:
|
|
131
|
+
errors.append(
|
|
132
|
+
f"CACHE_EXACT_MATCH_THRESHOLD 必须在 0-1 之间,当前值: {config.CACHE_EXACT_MATCH_THRESHOLD}"
|
|
133
|
+
)
|
|
134
|
+
if not config.WEB_CDP_URL.startswith(("http://", "https://")):
|
|
135
|
+
errors.append(
|
|
136
|
+
f"WEB_CDP_URL 必须以 http:// 或 https:// 开头,当前值: {config.WEB_CDP_URL}"
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
"name": "config",
|
|
141
|
+
"ok": not errors,
|
|
142
|
+
"errors": errors,
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def check_module_import(module_name: str) -> Dict[str, object]:
|
|
147
|
+
try:
|
|
148
|
+
importlib.import_module(module_name)
|
|
149
|
+
return {"name": module_name, "ok": True, "error": ""}
|
|
150
|
+
except Exception as exc:
|
|
151
|
+
return {"name": module_name, "ok": False, "error": str(exc)}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def check_runtime_paths(script_dir: Path, run_dir: Path) -> Dict[str, object]:
|
|
155
|
+
script_dir = Path(script_dir)
|
|
156
|
+
run_dir = Path(run_dir)
|
|
157
|
+
script_dir.mkdir(parents=True, exist_ok=True)
|
|
158
|
+
run_dir.mkdir(parents=True, exist_ok=True)
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
"name": "runtime_paths",
|
|
162
|
+
"ok": script_dir.is_dir() and run_dir.is_dir(),
|
|
163
|
+
"script_dir": str(script_dir),
|
|
164
|
+
"run_dir": str(run_dir),
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def check_command_available(command_name: str) -> Dict[str, object]:
|
|
169
|
+
command_path = shutil.which(command_name)
|
|
170
|
+
return {
|
|
171
|
+
"name": command_name,
|
|
172
|
+
"ok": bool(command_path),
|
|
173
|
+
"path": command_path or "",
|
|
174
|
+
"hint": f"Ensure {command_name} is installed and available on PATH.",
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _is_environment_restricted_error(message: str) -> bool:
|
|
179
|
+
text = str(message).lower()
|
|
180
|
+
return any(
|
|
181
|
+
pattern in text
|
|
182
|
+
for pattern in (
|
|
183
|
+
"operation not permitted",
|
|
184
|
+
"permission denied",
|
|
185
|
+
"smartsocket",
|
|
186
|
+
)
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def check_tcp_endpoint(url: str, timeout_seconds: float = 1.0) -> Dict[str, object]:
|
|
191
|
+
normalized_url = normalize_loopback_url(url)
|
|
192
|
+
parsed = urlparse(normalized_url)
|
|
193
|
+
host = parsed.hostname
|
|
194
|
+
port = parsed.port
|
|
195
|
+
if not host or not port:
|
|
196
|
+
return {
|
|
197
|
+
"name": url,
|
|
198
|
+
"ok": False,
|
|
199
|
+
"error": "Cannot parse host or port from URL",
|
|
200
|
+
"hint": "Check that the URL contains a valid host and port.",
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
try:
|
|
204
|
+
with socket.create_connection((host, port), timeout=timeout_seconds):
|
|
205
|
+
return {"name": url, "ok": True, "error": "", "hint": ""}
|
|
206
|
+
except OSError as exc:
|
|
207
|
+
raw_error = str(exc)
|
|
208
|
+
if _is_environment_restricted_error(raw_error):
|
|
209
|
+
return {
|
|
210
|
+
"name": url,
|
|
211
|
+
"ok": False,
|
|
212
|
+
"error": "Current environment restricts local TCP connection checks",
|
|
213
|
+
"raw_error": raw_error,
|
|
214
|
+
"environment_restricted": True,
|
|
215
|
+
"hint": (
|
|
216
|
+
f"Try checking {url}/json/version directly from a host terminal, "
|
|
217
|
+
"or retry after relaxing local network permissions."
|
|
218
|
+
),
|
|
219
|
+
}
|
|
220
|
+
return {
|
|
221
|
+
"name": url,
|
|
222
|
+
"ok": False,
|
|
223
|
+
"error": raw_error,
|
|
224
|
+
"hint": f"Ensure the local service at {url} is running.",
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def check_android_device_connected() -> Dict[str, object]:
|
|
229
|
+
try:
|
|
230
|
+
result = subprocess.run(
|
|
231
|
+
["adb", "devices"],
|
|
232
|
+
capture_output=True,
|
|
233
|
+
text=True,
|
|
234
|
+
timeout=5,
|
|
235
|
+
check=False,
|
|
236
|
+
)
|
|
237
|
+
except Exception as exc:
|
|
238
|
+
return {
|
|
239
|
+
"name": "adb_devices",
|
|
240
|
+
"ok": False,
|
|
241
|
+
"devices": [],
|
|
242
|
+
"blocked_devices": [],
|
|
243
|
+
"error": str(exc),
|
|
244
|
+
"hint": "Ensure adb is executable and `adb devices` runs successfully.",
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if result.returncode != 0:
|
|
248
|
+
raw_error = result.stderr.strip() or result.stdout.strip() or "adb devices execution failed"
|
|
249
|
+
if _is_environment_restricted_error(raw_error):
|
|
250
|
+
return {
|
|
251
|
+
"name": "adb_devices",
|
|
252
|
+
"ok": False,
|
|
253
|
+
"devices": [],
|
|
254
|
+
"blocked_devices": [],
|
|
255
|
+
"error": "Current environment restricts local port access for adb daemon",
|
|
256
|
+
"raw_error": raw_error,
|
|
257
|
+
"environment_restricted": True,
|
|
258
|
+
"hint": "Run `adb devices` directly from a host terminal, or retry after relaxing local network permissions.",
|
|
259
|
+
}
|
|
260
|
+
return {
|
|
261
|
+
"name": "adb_devices",
|
|
262
|
+
"ok": False,
|
|
263
|
+
"devices": [],
|
|
264
|
+
"blocked_devices": [],
|
|
265
|
+
"error": raw_error,
|
|
266
|
+
"hint": "Ensure the adb service is running, then re-run `adb devices`.",
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
devices = []
|
|
270
|
+
blocked_devices = []
|
|
271
|
+
for raw_line in result.stdout.splitlines()[1:]:
|
|
272
|
+
line = raw_line.strip()
|
|
273
|
+
if not line:
|
|
274
|
+
continue
|
|
275
|
+
parts = line.split()
|
|
276
|
+
serial = parts[0]
|
|
277
|
+
status = parts[1] if len(parts) > 1 else "unknown"
|
|
278
|
+
if status == "device":
|
|
279
|
+
devices.append(serial)
|
|
280
|
+
else:
|
|
281
|
+
blocked_devices.append({"serial": serial, "status": status})
|
|
282
|
+
|
|
283
|
+
if devices:
|
|
284
|
+
return {
|
|
285
|
+
"name": "adb_devices",
|
|
286
|
+
"ok": True,
|
|
287
|
+
"devices": devices,
|
|
288
|
+
"blocked_devices": blocked_devices,
|
|
289
|
+
"error": "",
|
|
290
|
+
"hint": "",
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if blocked_devices:
|
|
294
|
+
blocked_desc = ", ".join(
|
|
295
|
+
f"{item['serial']}({item['status']})" for item in blocked_devices
|
|
296
|
+
)
|
|
297
|
+
error = f"Android device(s) detected but unavailable: {blocked_desc}"
|
|
298
|
+
else:
|
|
299
|
+
error = "No usable Android device detected"
|
|
300
|
+
|
|
301
|
+
return {
|
|
302
|
+
"name": "adb_devices",
|
|
303
|
+
"ok": False,
|
|
304
|
+
"devices": [],
|
|
305
|
+
"blocked_devices": blocked_devices,
|
|
306
|
+
"error": error,
|
|
307
|
+
"hint": "Connect a device, enable USB debugging, and accept the debug authorization prompt on the phone.",
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def check_cdp_debug_endpoint(url: str, timeout_seconds: float = 1.5) -> Dict[str, object]:
|
|
312
|
+
base_url = normalize_loopback_url(url).rstrip("/")
|
|
313
|
+
version_url = f"{base_url}/json/version"
|
|
314
|
+
try:
|
|
315
|
+
with urlopen(version_url, timeout=timeout_seconds) as response:
|
|
316
|
+
payload = json.loads(response.read().decode("utf-8"))
|
|
317
|
+
except Exception as exc:
|
|
318
|
+
raw_error = str(exc)
|
|
319
|
+
if _is_environment_restricted_error(raw_error):
|
|
320
|
+
return {
|
|
321
|
+
"name": "cdp_debug_endpoint",
|
|
322
|
+
"ok": False,
|
|
323
|
+
"browser": "",
|
|
324
|
+
"websocket_url": "",
|
|
325
|
+
"error": "Current environment restricts local HTTP debug endpoint checks",
|
|
326
|
+
"raw_error": raw_error,
|
|
327
|
+
"environment_restricted": True,
|
|
328
|
+
"hint": (
|
|
329
|
+
f"Try checking {version_url} directly from a host terminal, "
|
|
330
|
+
"or retry after relaxing local network permissions."
|
|
331
|
+
),
|
|
332
|
+
}
|
|
333
|
+
return {
|
|
334
|
+
"name": "cdp_debug_endpoint",
|
|
335
|
+
"ok": False,
|
|
336
|
+
"browser": "",
|
|
337
|
+
"websocket_url": "",
|
|
338
|
+
"error": raw_error,
|
|
339
|
+
"hint": "Launch Chrome with `--remote-debugging-port=9222` and ensure the CDP address is reachable.",
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
websocket_url = payload.get("webSocketDebuggerUrl", "")
|
|
343
|
+
if not websocket_url:
|
|
344
|
+
return {
|
|
345
|
+
"name": "cdp_debug_endpoint",
|
|
346
|
+
"ok": False,
|
|
347
|
+
"browser": payload.get("Browser", ""),
|
|
348
|
+
"websocket_url": "",
|
|
349
|
+
"error": "CDP metadata missing webSocketDebuggerUrl",
|
|
350
|
+
"hint": "Ensure the port exposes Chrome DevTools Protocol, not a regular HTTP service.",
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return {
|
|
354
|
+
"name": "cdp_debug_endpoint",
|
|
355
|
+
"ok": True,
|
|
356
|
+
"browser": payload.get("Browser", ""),
|
|
357
|
+
"websocket_url": websocket_url,
|
|
358
|
+
"error": "",
|
|
359
|
+
"hint": "",
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def check_wda_status_endpoint(url: str, timeout_seconds: float = 1.5) -> Dict[str, object]:
|
|
364
|
+
status_url = f"{url.rstrip('/')}/status"
|
|
365
|
+
try:
|
|
366
|
+
with urlopen(status_url, timeout=timeout_seconds) as response:
|
|
367
|
+
payload = json.loads(response.read().decode("utf-8"))
|
|
368
|
+
except Exception as exc:
|
|
369
|
+
return {
|
|
370
|
+
"name": "wda_status",
|
|
371
|
+
"ok": False,
|
|
372
|
+
"platform_name": "",
|
|
373
|
+
"platform_version": "",
|
|
374
|
+
"message": "",
|
|
375
|
+
"error": str(exc),
|
|
376
|
+
"hint": "Ensure WebDriverAgent is running on the iPhone and port 8100 is mapped.",
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
value = payload.get("value")
|
|
380
|
+
if not isinstance(value, dict):
|
|
381
|
+
return {
|
|
382
|
+
"name": "wda_status",
|
|
383
|
+
"ok": False,
|
|
384
|
+
"platform_name": "",
|
|
385
|
+
"platform_version": "",
|
|
386
|
+
"message": "",
|
|
387
|
+
"error": "WDA status response missing 'value' field",
|
|
388
|
+
"hint": "Ensure port 8100 exposes a valid WebDriverAgent status endpoint.",
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
state = str(value.get("state", "")).strip()
|
|
392
|
+
if state.lower() != "success":
|
|
393
|
+
return {
|
|
394
|
+
"name": "wda_status",
|
|
395
|
+
"ok": False,
|
|
396
|
+
"platform_name": "",
|
|
397
|
+
"platform_version": "",
|
|
398
|
+
"message": str(value.get("message", "")).strip(),
|
|
399
|
+
"error": f"WDA state is abnormal: {state or 'unknown'}",
|
|
400
|
+
"hint": "Check that WebDriverAgent is properly installed and signed, and the device trusts the developer certificate.",
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
os_info = value.get("os", {}) if isinstance(value.get("os"), dict) else {}
|
|
404
|
+
return {
|
|
405
|
+
"name": "wda_status",
|
|
406
|
+
"ok": True,
|
|
407
|
+
"platform_name": str(os_info.get("name", "")).strip(),
|
|
408
|
+
"platform_version": str(os_info.get("version", "")).strip(),
|
|
409
|
+
"message": str(value.get("message", "")).strip(),
|
|
410
|
+
"error": "",
|
|
411
|
+
"hint": "",
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def check_orphan_web_browser() -> Dict[str, object]:
|
|
416
|
+
"""Report a leftover persistent Chromium still holding the CDP port.
|
|
417
|
+
|
|
418
|
+
The web adapter launches Chromium detached on port 9333 and keeps it alive
|
|
419
|
+
across CLI calls (teardown only disconnects) so later runs can reconnect.
|
|
420
|
+
`--web-stop` reaps it, but until now doctor had no way to *notice* one was
|
|
421
|
+
leaked. This closes the loop: read report/web_session.json, and if the
|
|
422
|
+
recorded pid is still alive, surface it as a finding.
|
|
423
|
+
|
|
424
|
+
ADVISORY: a live persistent browser is the design's *intended* reconnect
|
|
425
|
+
target, not a fault, so this check is marked `advisory` — run_preflight
|
|
426
|
+
excludes advisory findings from the pass/fail aggregate. It informs ("a
|
|
427
|
+
browser is up, run --web-stop to reclaim it") without failing --doctor.
|
|
428
|
+
|
|
429
|
+
Reuses web_adapter's _read_session / _is_process_alive so the zombie-aware
|
|
430
|
+
liveness logic stays single-sourced.
|
|
431
|
+
"""
|
|
432
|
+
from common.adapters import web_adapter
|
|
433
|
+
|
|
434
|
+
ok_result = {
|
|
435
|
+
"name": "orphan_web_browser",
|
|
436
|
+
"ok": True,
|
|
437
|
+
"advisory": True,
|
|
438
|
+
"pid": 0,
|
|
439
|
+
"cdp_url": "",
|
|
440
|
+
"error": "",
|
|
441
|
+
"hint": "",
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
session = web_adapter._read_session()
|
|
445
|
+
if not session:
|
|
446
|
+
return ok_result
|
|
447
|
+
|
|
448
|
+
pid = session.get("pid", 0)
|
|
449
|
+
cdp_url = session.get("cdp_url", "")
|
|
450
|
+
if not pid or not web_adapter._is_process_alive(pid):
|
|
451
|
+
return ok_result
|
|
452
|
+
|
|
453
|
+
return {
|
|
454
|
+
"name": "orphan_web_browser",
|
|
455
|
+
"ok": False,
|
|
456
|
+
"advisory": True,
|
|
457
|
+
"pid": pid,
|
|
458
|
+
"cdp_url": cdp_url,
|
|
459
|
+
"error": f"Persistent Chromium still running (pid {pid}, {cdp_url})",
|
|
460
|
+
"hint": "Run `screenforge --web-stop` to reclaim it (this is a note, not a failure).",
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def run_preflight(platform: str, script_dir: Path, run_dir: Path) -> Dict[str, object]:
|
|
465
|
+
checks = [
|
|
466
|
+
check_required_config(),
|
|
467
|
+
check_runtime_paths(script_dir, run_dir),
|
|
468
|
+
check_virtualenv_consistency(config.BASE_DIR),
|
|
469
|
+
]
|
|
470
|
+
|
|
471
|
+
if platform == "android":
|
|
472
|
+
checks.append(check_module_import("uiautomator2"))
|
|
473
|
+
checks.append(check_command_available("adb"))
|
|
474
|
+
checks.append(check_android_device_connected())
|
|
475
|
+
elif platform == "ios":
|
|
476
|
+
checks.append(check_module_import("wda"))
|
|
477
|
+
ios_endpoint_check = check_tcp_endpoint("http://localhost:8100")
|
|
478
|
+
checks.append(ios_endpoint_check)
|
|
479
|
+
if ios_endpoint_check.get("ok", False):
|
|
480
|
+
checks.append(check_wda_status_endpoint("http://localhost:8100"))
|
|
481
|
+
elif platform == "web":
|
|
482
|
+
checks.append(check_module_import("playwright"))
|
|
483
|
+
web_endpoint_check = check_tcp_endpoint(config.WEB_CDP_URL)
|
|
484
|
+
checks.append(web_endpoint_check)
|
|
485
|
+
if web_endpoint_check.get("ok", False):
|
|
486
|
+
checks.append(check_cdp_debug_endpoint(config.WEB_CDP_URL))
|
|
487
|
+
checks.append(check_orphan_web_browser())
|
|
488
|
+
|
|
489
|
+
# Advisory findings (e.g. a healthy persistent browser still up) inform but
|
|
490
|
+
# must NOT flip the pass/fail verdict or exit code — only real blockers do.
|
|
491
|
+
ok = all(item.get("ok", False) for item in checks if not item.get("advisory", False))
|
|
492
|
+
return {
|
|
493
|
+
"ok": ok,
|
|
494
|
+
"platform": platform,
|
|
495
|
+
"checks": checks,
|
|
496
|
+
}
|
common/progress.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Rich progress indicators for AI operations."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from contextlib import contextmanager
|
|
5
|
+
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
|
|
8
|
+
_console = Console(stderr=True)
|
|
9
|
+
|
|
10
|
+
_TOOL_MODE = False
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def set_tool_mode(enabled: bool = True):
|
|
14
|
+
"""Disable spinners when running in tool/MCP mode (stdout is structured)."""
|
|
15
|
+
global _TOOL_MODE
|
|
16
|
+
_TOOL_MODE = enabled
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@contextmanager
|
|
20
|
+
def ai_status(message: str = "Calling AI..."):
|
|
21
|
+
if _TOOL_MODE or not sys.stderr.isatty():
|
|
22
|
+
yield
|
|
23
|
+
return
|
|
24
|
+
with _console.status(f"[bold cyan]{message}[/]", spinner="dots") as status:
|
|
25
|
+
yield status
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@contextmanager
|
|
29
|
+
def action_status(action: str, target: str = ""):
|
|
30
|
+
label = f"Executing: {action}"
|
|
31
|
+
if target:
|
|
32
|
+
label += f" → {target}"
|
|
33
|
+
if _TOOL_MODE or not sys.stderr.isatty():
|
|
34
|
+
yield
|
|
35
|
+
return
|
|
36
|
+
with _console.status(f"[bold green]{label}[/]", spinner="dots") as status:
|
|
37
|
+
yield status
|