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.
Files changed (64) hide show
  1. cli/__init__.py +0 -0
  2. cli/_version.py +1 -0
  3. cli/dispatch.py +266 -0
  4. cli/doctor.py +487 -0
  5. cli/modes/__init__.py +0 -0
  6. cli/modes/action.py +262 -0
  7. cli/modes/default.py +248 -0
  8. cli/modes/demo.py +162 -0
  9. cli/modes/dry_run.py +237 -0
  10. cli/modes/init.py +133 -0
  11. cli/modes/plan.py +148 -0
  12. cli/modes/workflow.py +354 -0
  13. cli/parser.py +305 -0
  14. cli/reporter.py +207 -0
  15. cli/session.py +146 -0
  16. cli/shared.py +427 -0
  17. cli/shorthand.py +90 -0
  18. cli/tool_protocol_handlers.py +446 -0
  19. common/__init__.py +0 -0
  20. common/adapters/__init__.py +21 -0
  21. common/adapters/android_adapter.py +273 -0
  22. common/adapters/base_adapter.py +24 -0
  23. common/adapters/ios_adapter.py +278 -0
  24. common/adapters/web_adapter.py +271 -0
  25. common/ai.py +277 -0
  26. common/ai_autonomous.py +273 -0
  27. common/ai_heal.py +222 -0
  28. common/cache/__init__.py +15 -0
  29. common/cache/cache_hash.py +57 -0
  30. common/cache/cache_manager.py +300 -0
  31. common/cache/cache_stats.py +133 -0
  32. common/cache/cache_storage.py +79 -0
  33. common/cache/embedding_loader.py +150 -0
  34. common/capabilities.py +121 -0
  35. common/case_memory.py +327 -0
  36. common/error_codes.py +61 -0
  37. common/exceptions.py +18 -0
  38. common/executor.py +1504 -0
  39. common/failure_diagnosis.py +138 -0
  40. common/history_manager.py +75 -0
  41. common/logs.py +168 -0
  42. common/mcp_server.py +467 -0
  43. common/preflight.py +496 -0
  44. common/progress.py +37 -0
  45. common/run_reporter.py +415 -0
  46. common/run_resume.py +149 -0
  47. common/runtime_modes.py +35 -0
  48. common/tool_protocol.py +196 -0
  49. common/visual_fallback.py +71 -0
  50. common/workflow_schema.py +150 -0
  51. config/__init__.py +0 -0
  52. config/config.py +167 -0
  53. config/env_loader.py +76 -0
  54. screenforge-0.4.0.dist-info/METADATA +43 -0
  55. screenforge-0.4.0.dist-info/RECORD +64 -0
  56. screenforge-0.4.0.dist-info/WHEEL +5 -0
  57. screenforge-0.4.0.dist-info/entry_points.txt +2 -0
  58. screenforge-0.4.0.dist-info/licenses/LICENSE +21 -0
  59. screenforge-0.4.0.dist-info/top_level.txt +4 -0
  60. utils/__init__.py +0 -0
  61. utils/screenshot_annotator.py +60 -0
  62. utils/utils_ios.py +195 -0
  63. utils/utils_web.py +304 -0
  64. utils/utils_xml.py +218 -0
@@ -0,0 +1,196 @@
1
+ import json
2
+ from pathlib import Path
3
+ from typing import Literal
4
+
5
+ from pydantic import BaseModel, Field, ValidationError, model_validator
6
+
7
+ from common.capabilities import (
8
+ ACTIONS_REQUIRING_EXTRA_VALUE,
9
+ GLOBAL_ACTIONS,
10
+ SUPPORTED_ACTIONS,
11
+ SUPPORTED_PLATFORMS,
12
+ get_capabilities_payload,
13
+ )
14
+ from common.runtime_modes import MODE_DOCTOR, MODE_DRY_RUN, MODE_PLAN_ONLY, MODE_RUN
15
+
16
+ SUPPORTED_TOOL_MODES = (MODE_RUN, MODE_DOCTOR, MODE_PLAN_ONLY, MODE_DRY_RUN)
17
+
18
+
19
+ class ToolRequestError(ValueError):
20
+ pass
21
+
22
+
23
+ class WorkflowToolControl(BaseModel):
24
+ path: str
25
+ vars: dict[str, str] = Field(default_factory=dict)
26
+
27
+
28
+ class ActionToolControl(BaseModel):
29
+ action: str
30
+ action_name: str = ""
31
+ locator_type: str = ""
32
+ locator_value: str = ""
33
+ extra_value: str = ""
34
+
35
+ @model_validator(mode="after")
36
+ def validate_action(self):
37
+ if self.action not in SUPPORTED_ACTIONS:
38
+ raise ValueError(f"Unsupported action: {self.action}")
39
+ # Use the shared GLOBAL_ACTIONS set, not a hardcoded literal: a global
40
+ # action (goto/swipe/press/assert_url) needs no locator. A literal here
41
+ # drifts from the canonical set (e.g. it wrongly demanded a locator for
42
+ # assert_url, which reads page.url).
43
+ if self.action not in GLOBAL_ACTIONS:
44
+ if not str(self.locator_type).strip() or not str(self.locator_value).strip():
45
+ raise ValueError("Element actions require locator_type and locator_value")
46
+ if self.action in ACTIONS_REQUIRING_EXTRA_VALUE and not str(self.extra_value).strip():
47
+ raise ValueError(f"Action '{self.action}' requires extra_value")
48
+ return self
49
+
50
+
51
+ class ToolRequest(BaseModel):
52
+ operation: Literal[
53
+ "capabilities",
54
+ "execute",
55
+ "load_run",
56
+ "inspect_ui",
57
+ "load_case_memory",
58
+ ]
59
+ mode: Literal["run", "doctor", "plan_only", "dry_run"] = MODE_RUN
60
+ platform: str = ""
61
+ env: str = "dev"
62
+ vision: bool = False
63
+ context: str = ""
64
+ output: str = ""
65
+ resume_run_id: str = ""
66
+ run_id: str = ""
67
+ goal: str = ""
68
+ query: str = ""
69
+ source_ref: str = ""
70
+ control_kind: str = ""
71
+ limit: int = 20
72
+ workflow: WorkflowToolControl | None = None
73
+ action: ActionToolControl | None = None
74
+
75
+ @model_validator(mode="after")
76
+ def validate_request(self):
77
+ if self.operation == "capabilities":
78
+ return self
79
+
80
+ if _normalize_text := str(self.goal).strip():
81
+ if self.operation == "execute":
82
+ raise ValueError(
83
+ "Execute tool request does not accept goal; the calling Agent should interpret natural language and pass workflow or action"
84
+ )
85
+ raise ValueError(f"{self.operation} tool request cannot include goal")
86
+
87
+ if self.operation in {"execute", "inspect_ui"} and self.platform not in SUPPORTED_PLATFORMS:
88
+ raise ValueError(f"Unsupported platform: {self.platform}")
89
+
90
+ control_count = int(self.workflow is not None) + int(self.action is not None)
91
+ if self.operation == "load_run":
92
+ if control_count:
93
+ raise ValueError("load_run tool request cannot include workflow or action")
94
+ if not str(self.run_id).strip():
95
+ raise ValueError("load_run tool request requires run_id")
96
+ return self
97
+ if self.operation == "load_case_memory":
98
+ if control_count:
99
+ raise ValueError("load_case_memory tool request cannot include workflow or action")
100
+ return self
101
+ if self.operation == "inspect_ui":
102
+ if control_count:
103
+ raise ValueError("inspect_ui tool request cannot include workflow or action")
104
+ return self
105
+
106
+ if self.mode == MODE_DOCTOR:
107
+ if control_count:
108
+ raise ValueError("doctor tool request cannot include workflow or action")
109
+ return self
110
+
111
+ if control_count != 1:
112
+ raise ValueError(
113
+ "Execute tool request must provide exactly one control: workflow or action"
114
+ )
115
+ return self
116
+
117
+
118
+ def _validate_tool_request_payload(payload: dict, source_label: str) -> ToolRequest:
119
+ try:
120
+ return ToolRequest.model_validate(payload)
121
+ except ValidationError as exc:
122
+ raise ToolRequestError(f"{source_label} validation failed: {exc}") from exc
123
+
124
+
125
+ def load_tool_request(file_path: str | Path) -> ToolRequest:
126
+ path = Path(file_path).expanduser().resolve()
127
+ if not path.exists():
128
+ raise ToolRequestError(f"Tool request file not found: {path}")
129
+
130
+ try:
131
+ payload = json.loads(path.read_text(encoding="utf-8"))
132
+ except json.JSONDecodeError as exc:
133
+ raise ToolRequestError(f"Tool request JSON parse error: {exc}") from exc
134
+
135
+ return _validate_tool_request_payload(payload, "tool request")
136
+
137
+
138
+ def load_tool_request_from_stdin(raw_text: str) -> ToolRequest:
139
+ if not str(raw_text).strip():
140
+ raise ToolRequestError("Tool stdin is empty")
141
+
142
+ try:
143
+ payload = json.loads(raw_text)
144
+ except json.JSONDecodeError as exc:
145
+ raise ToolRequestError(f"Tool stdin JSON parse error: {exc}") from exc
146
+
147
+ return _validate_tool_request_payload(payload, "tool stdin")
148
+
149
+
150
+ def build_cli_arg_overrides(request: ToolRequest) -> dict:
151
+ overrides = {
152
+ "goal": "",
153
+ "context": request.context,
154
+ "env": request.env,
155
+ "platform": request.platform,
156
+ "vision": request.vision,
157
+ "json": False,
158
+ "doctor": request.mode == MODE_DOCTOR,
159
+ "plan_only": request.mode == MODE_PLAN_ONLY,
160
+ "dry_run": request.mode == MODE_DRY_RUN,
161
+ "resume_run_id": request.resume_run_id,
162
+ "workflow": "",
163
+ "workflow_var": [],
164
+ "action": "",
165
+ "action_name": "",
166
+ "locator_type": "",
167
+ "locator_value": "",
168
+ "extra_value": "",
169
+ "output": request.output,
170
+ "capabilities": False,
171
+ "tool_request": "",
172
+ "tool_stdin": False,
173
+ "mcp_server": False,
174
+ }
175
+
176
+ if request.workflow:
177
+ overrides["workflow"] = request.workflow.path
178
+ overrides["workflow_var"] = [
179
+ f"{key}={value}" for key, value in request.workflow.vars.items()
180
+ ]
181
+ elif request.action:
182
+ overrides["action"] = request.action.action
183
+ overrides["action_name"] = request.action.action_name
184
+ overrides["locator_type"] = request.action.locator_type
185
+ overrides["locator_value"] = request.action.locator_value
186
+ overrides["extra_value"] = request.action.extra_value
187
+
188
+ return overrides
189
+
190
+
191
+ def build_capabilities_response() -> dict:
192
+ return {
193
+ "ok": True,
194
+ "operation": "capabilities",
195
+ "capabilities": get_capabilities_payload(),
196
+ }
@@ -0,0 +1,71 @@
1
+ """Visual fallback: locate elements via VLM screenshot when DOM lookup fails."""
2
+
3
+ import json
4
+ import re
5
+
6
+ from common.logs import log
7
+
8
+
9
+ def visual_locate(screenshot_bytes: bytes, description: str) -> tuple[int, int] | None:
10
+ import base64
11
+
12
+ import config.config as config
13
+
14
+ try:
15
+ from openai import OpenAI
16
+ except ImportError:
17
+ log.error("[Visual Fallback] openai package not installed")
18
+ return None
19
+
20
+ screenshot_b64 = base64.b64encode(screenshot_bytes).decode("utf-8")
21
+
22
+ prompt = (
23
+ f"你是一个 UI 元素定位专家。用户在页面上找不到元素: {description}\n"
24
+ "请在截图中找到最匹配的元素, 返回该元素中心点的像素坐标。\n"
25
+ "只返回 JSON, 格式: {\"x\": 数字, \"y\": 数字}\n"
26
+ "如果找不到, 返回: {\"x\": -1, \"y\": -1}"
27
+ )
28
+
29
+ try:
30
+ client = OpenAI(
31
+ api_key=config.VISION_API_KEY,
32
+ base_url=config.VISION_BASE_URL,
33
+ )
34
+ response = client.chat.completions.create(
35
+ model=config.VISION_MODEL_NAME,
36
+ messages=[
37
+ {
38
+ "role": "user",
39
+ "content": [
40
+ {"type": "text", "text": prompt},
41
+ {
42
+ "type": "image_url",
43
+ "image_url": {"url": f"data:image/png;base64,{screenshot_b64}"},
44
+ },
45
+ ],
46
+ }
47
+ ],
48
+ max_tokens=100,
49
+ temperature=0,
50
+ )
51
+
52
+ raw = response.choices[0].message.content.strip()
53
+ json_match = re.search(r"\{[^}]+\}", raw)
54
+ if not json_match:
55
+ log.warning(f"[Visual Fallback] VLM response not parseable: {raw[:200]}")
56
+ return None
57
+
58
+ coords = json.loads(json_match.group())
59
+ x = int(coords.get("x", -1))
60
+ y = int(coords.get("y", -1))
61
+
62
+ if x < 0 or y < 0:
63
+ log.warning("[Visual Fallback] VLM could not locate target element")
64
+ return None
65
+
66
+ log.info(f"[Visual Fallback] VLM located element at ({x}, {y})")
67
+ return (x, y)
68
+
69
+ except Exception as e:
70
+ log.error(f"[Visual Fallback] VLM call failed: {e}")
71
+ return None
@@ -0,0 +1,150 @@
1
+ import re
2
+ from pathlib import Path
3
+ from typing import Literal
4
+
5
+ import yaml
6
+ from pydantic import BaseModel, Field, model_validator
7
+
8
+ from common.capabilities import GLOBAL_ACTIONS, SUPPORTED_ACTIONS
9
+
10
+ SUPPORTED_WORKFLOW_ACTIONS = SUPPORTED_ACTIONS
11
+ GLOBAL_WORKFLOW_ACTIONS = GLOBAL_ACTIONS
12
+ WORKFLOW_VAR_PATTERN = re.compile(r"{{\s*([a-zA-Z0-9_\-]+)\s*}}")
13
+
14
+
15
+ class WorkflowLoadError(ValueError):
16
+ pass
17
+
18
+
19
+ class WorkflowStep(BaseModel):
20
+ name: str = ""
21
+ action: Literal[
22
+ "goto",
23
+ "click",
24
+ "long_click",
25
+ "hover",
26
+ "input",
27
+ "swipe",
28
+ "press",
29
+ "scroll_into_view",
30
+ "select",
31
+ "upload",
32
+ "double_click",
33
+ "right_click",
34
+ "drag",
35
+ "wait_for",
36
+ "assert_exist",
37
+ "assert_not_exist",
38
+ "assert_text_equals",
39
+ "assert_text_contains",
40
+ "assert_value",
41
+ "assert_url",
42
+ ]
43
+ locator_type: str = "global"
44
+ locator_value: str = "global"
45
+ extra_value: str = ""
46
+ enabled: bool = True
47
+
48
+ @model_validator(mode="after")
49
+ def validate_locator(self):
50
+ if not self.enabled:
51
+ return self
52
+
53
+ if self.action in GLOBAL_WORKFLOW_ACTIONS:
54
+ if not str(self.locator_type).strip():
55
+ self.locator_type = "global"
56
+ if not str(self.locator_value).strip():
57
+ self.locator_value = "global"
58
+ return self
59
+
60
+ if not str(self.locator_type).strip() or not str(self.locator_value).strip():
61
+ raise ValueError("Element workflow steps require locator_type and locator_value")
62
+
63
+ return self
64
+
65
+
66
+ class WorkflowDefinition(BaseModel):
67
+ version: int = 1
68
+ name: str = ""
69
+ platform: str = ""
70
+ env: str = ""
71
+ vars: dict[str, str] = Field(default_factory=dict)
72
+ steps: list[WorkflowStep] = Field(min_length=1)
73
+
74
+
75
+ def load_workflow_file(file_path: str | Path) -> WorkflowDefinition:
76
+ path = Path(file_path).expanduser().resolve()
77
+ if not path.exists():
78
+ raise WorkflowLoadError(f"Workflow file not found: {path}")
79
+
80
+ try:
81
+ payload = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
82
+ except yaml.YAMLError as exc:
83
+ raise WorkflowLoadError(f"Workflow YAML parse error: {exc}") from exc
84
+
85
+ if not isinstance(payload, dict):
86
+ raise WorkflowLoadError("Workflow file top-level must be an object")
87
+
88
+ try:
89
+ return WorkflowDefinition.model_validate(payload)
90
+ except Exception as exc:
91
+ raise WorkflowLoadError(f"Workflow validation failed: {exc}") from exc
92
+
93
+
94
+ def parse_workflow_var_overrides(items: list[str] | None) -> dict[str, str]:
95
+ resolved = {}
96
+ for item in items or []:
97
+ if "=" not in str(item):
98
+ raise WorkflowLoadError("Workflow variable override format must be KEY=VALUE")
99
+ key, value = str(item).split("=", 1)
100
+ key = key.strip()
101
+ if not key:
102
+ raise WorkflowLoadError("Workflow variable name cannot be empty")
103
+ resolved[key] = value
104
+ return resolved
105
+
106
+
107
+ def _render_template(value: str, variables: dict[str, str]) -> str:
108
+ text = str(value)
109
+
110
+ def replace(match: re.Match[str]) -> str:
111
+ key = match.group(1)
112
+ if key not in variables:
113
+ raise WorkflowLoadError(f"Workflow references undefined variable: {key}")
114
+ return str(variables[key])
115
+
116
+ return WORKFLOW_VAR_PATTERN.sub(replace, text)
117
+
118
+
119
+ def resolve_workflow_definition(
120
+ workflow: WorkflowDefinition,
121
+ overrides: dict[str, str] | None = None,
122
+ ) -> WorkflowDefinition:
123
+ variables = {key: str(value) for key, value in workflow.vars.items()}
124
+ variables.update({key: str(value) for key, value in (overrides or {}).items()})
125
+
126
+ resolved_steps = []
127
+ for step in workflow.steps:
128
+ resolved_steps.append(
129
+ WorkflowStep.model_validate(
130
+ {
131
+ "name": _render_template(step.name, variables),
132
+ "action": step.action,
133
+ "locator_type": _render_template(step.locator_type, variables),
134
+ "locator_value": _render_template(step.locator_value, variables),
135
+ "extra_value": _render_template(step.extra_value, variables),
136
+ "enabled": step.enabled,
137
+ }
138
+ )
139
+ )
140
+
141
+ return WorkflowDefinition.model_validate(
142
+ {
143
+ "version": workflow.version,
144
+ "name": _render_template(workflow.name, variables),
145
+ "platform": _render_template(workflow.platform, variables),
146
+ "env": _render_template(workflow.env, variables),
147
+ "vars": variables,
148
+ "steps": [step.model_dump() for step in resolved_steps],
149
+ }
150
+ )
config/__init__.py ADDED
File without changes
config/config.py ADDED
@@ -0,0 +1,167 @@
1
+ import os
2
+ from pathlib import Path
3
+
4
+ from common.logs import log
5
+ from config.env_loader import resolve_dotenv_path, safe_load_dotenv
6
+
7
+ # ==========================================
8
+ # 1. 基础路径与 .env 自动加载 (工程化核心)
9
+ # ==========================================
10
+ # 动态获取项目根目录 (config.py 的上一级目录)
11
+ BASE_DIR = Path(__file__).resolve().parent.parent
12
+
13
+ # 尝试寻找并加载根目录或上层主仓库中的 .env/.ENV 文件到系统环境变量中
14
+ # override=False 表示如果宿主系统已经配置了该变量(如在 CI/CD 流水线中),则以系统优先
15
+ env_path = resolve_dotenv_path(BASE_DIR)
16
+ safe_load_dotenv(dotenv_path=env_path, override=False)
17
+
18
+ # ==========================================
19
+ # 2. 文本大模型配置 (用于处理纯 XML 树,高频、廉价、快速)
20
+ # ==========================================
21
+ OPENAI_API_KEY = os.getenv("OPENAI_API_KEY", "")
22
+ OPENAI_BASE_URL = os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1")
23
+ MODEL_NAME = os.getenv("MODEL_NAME", "gpt-4o")
24
+
25
+ # ==========================================
26
+ # 3. 多模态视觉大模型配置 (用于处理屏幕截图,低频、复杂场景辅助)
27
+ # ==========================================
28
+ # 默认 fallback 到文本模型的配置,实现优雅降级;若配置了则实现异构解耦
29
+ VISION_API_KEY = os.getenv("VISION_API_KEY", OPENAI_API_KEY)
30
+ VISION_BASE_URL = os.getenv("VISION_BASE_URL", OPENAI_BASE_URL)
31
+ VISION_MODEL_NAME = os.getenv("VISION_MODEL_NAME", MODEL_NAME)
32
+
33
+ # ==========================================
34
+ # 4. 自动化自愈配置 (Self-Healing)
35
+ # ==========================================
36
+ # 开启 AI 自动修复失败用例功能
37
+ AUTO_HEAL_ENABLED = str(os.getenv("AUTO_HEAL_ENABLED", "True")).lower() in ('true', '1', 'yes')
38
+ # 连续失败多少次后触发自愈
39
+ AUTO_HEAL_TRIGGER_THRESHOLD = int(os.getenv("AUTO_HEAL_TRIGGER_THRESHOLD", 2))
40
+ # 自愈置信度阈值 — 低于此值的修复方案将被丢弃
41
+ AUTO_HEAL_MIN_CONFIDENCE = float(os.getenv("AUTO_HEAL_MIN_CONFIDENCE", 0.7))
42
+
43
+ # ==========================================
44
+ # 5. 自动化测试框架配置
45
+ # ==========================================
46
+ # 使用绝对路径,彻底杜绝不同命令路径下生成文件夹位置错乱的问题
47
+ OUTPUT_SCRIPT_FILE = str(BASE_DIR / "test_cases" / "test_auto_generated.py")
48
+
49
+ # 全局隐式等待时间,强转为 float 确保安全
50
+ DEFAULT_TIMEOUT = float(os.getenv("DEFAULT_TIMEOUT", 30.0))
51
+
52
+ # ==========================================
53
+ # 6. App 环境配置
54
+ # ==========================================
55
+ APP_ENV_CONFIG = {
56
+ "dev": {
57
+ "android": "",
58
+ "ios": "",
59
+ "web": "",
60
+ },
61
+ }
62
+
63
+ # ==========================================
64
+ # 7. 本地语义缓存配置
65
+ # ==========================================
66
+ # 强转布尔值,兼容 .env 中的 True/true/1/yes
67
+ CACHE_ENABLED = str(os.getenv("CACHE_ENABLED", "True")).lower() in ('true', '1', 't', 'yes')
68
+ CACHE_DIR = str(BASE_DIR / '.cache')
69
+ CACHE_TTL_DAYS = int(os.getenv("CACHE_TTL_DAYS", 7))
70
+ CACHE_MAX_SIZE_MB = int(os.getenv("CACHE_MAX_SIZE_MB", 100))
71
+ CACHE_COMPRESSION = str(os.getenv("CACHE_COMPRESSION", "False")).lower() in ('true', '1', 't', 'yes')
72
+
73
+ CACHE_SIMILARITY_THRESHOLD = float(os.getenv("CACHE_SIMILARITY_THRESHOLD", "0.90"))
74
+ CACHE_EXACT_MATCH_THRESHOLD = float(os.getenv("CACHE_EXACT_MATCH_THRESHOLD", "0.98"))
75
+
76
+
77
+ # ==========================================
78
+ # 8. Web CDP 连接配置
79
+ # ==========================================
80
+ WEB_CDP_URL = os.getenv("WEB_CDP_URL", "http://localhost:9222")
81
+
82
+ # ==========================================
83
+ # 8a. Android 设备连接配置
84
+ # ==========================================
85
+ ANDROID_SERIAL = os.getenv("ANDROID_SERIAL", "")
86
+ ANDROID_CONNECT_TIMEOUT = float(os.getenv("ANDROID_CONNECT_TIMEOUT", "10.0"))
87
+
88
+ # ==========================================
89
+ # 8b. iOS 设备连接配置
90
+ # ==========================================
91
+ WDA_URL = os.getenv("WDA_URL", "http://localhost:8100")
92
+ IOS_DEVICE_UDID = os.getenv("IOS_DEVICE_UDID", "")
93
+
94
+ # ==========================================
95
+ # 9. Agent 运行产物目录
96
+ # ==========================================
97
+ RUN_REPORT_BASE_DIR = BASE_DIR / "report" / "runs"
98
+
99
+ # ==========================================
100
+ # 10. 跨运行测试记忆目录
101
+ # ==========================================
102
+ CASE_MEMORY_PATH = Path(os.getenv("CASE_MEMORY_PATH", str(BASE_DIR / "memory" / "case_memory.json")))
103
+
104
+ # ==========================================
105
+ # 11. Pytest 真机回放测试控制
106
+ # ==========================================
107
+ TEST_PLATFORM = os.getenv("TEST_PLATFORM", "android").lower()
108
+ RUN_LIVE_PLATFORM_TESTS = str(os.getenv("RUN_LIVE_PLATFORM_TESTS", "False")).lower() in (
109
+ "true",
110
+ "1",
111
+ "t",
112
+ "yes",
113
+ )
114
+
115
+
116
+ def validate_config() -> bool:
117
+ """Validate required configuration. Returns False and logs errors on failure."""
118
+ errors: list[tuple[str, str, str]] = [] # (code, what, fix)
119
+
120
+ if not OPENAI_API_KEY:
121
+ errors.append((
122
+ "E001",
123
+ "OPENAI_API_KEY is not set.",
124
+ "Fix: export OPENAI_API_KEY=sk-... or add it to your .env file",
125
+ ))
126
+ if DEFAULT_TIMEOUT <= 0:
127
+ errors.append((
128
+ "E002",
129
+ f"DEFAULT_TIMEOUT must be > 0 (current: {DEFAULT_TIMEOUT}).",
130
+ "Fix: export DEFAULT_TIMEOUT=30",
131
+ ))
132
+ if not (0 <= CACHE_SIMILARITY_THRESHOLD <= 1):
133
+ errors.append((
134
+ "E003",
135
+ f"CACHE_SIMILARITY_THRESHOLD must be 0-1 (current: {CACHE_SIMILARITY_THRESHOLD}).",
136
+ "Fix: export CACHE_SIMILARITY_THRESHOLD=0.90",
137
+ ))
138
+ if not (0 <= CACHE_EXACT_MATCH_THRESHOLD <= 1):
139
+ errors.append((
140
+ "E004",
141
+ f"CACHE_EXACT_MATCH_THRESHOLD must be 0-1 (current: {CACHE_EXACT_MATCH_THRESHOLD}).",
142
+ "Fix: export CACHE_EXACT_MATCH_THRESHOLD=0.98",
143
+ ))
144
+ if not WEB_CDP_URL.startswith(("http://", "https://")):
145
+ errors.append((
146
+ "E005",
147
+ f"WEB_CDP_URL must start with http:// or https:// (current: {WEB_CDP_URL}).",
148
+ "Fix: export WEB_CDP_URL=http://localhost:9222",
149
+ ))
150
+ if not (0 <= AUTO_HEAL_MIN_CONFIDENCE <= 1):
151
+ errors.append((
152
+ "E006",
153
+ f"AUTO_HEAL_MIN_CONFIDENCE must be 0-1 (current: {AUTO_HEAL_MIN_CONFIDENCE}).",
154
+ "Fix: export AUTO_HEAL_MIN_CONFIDENCE=0.7",
155
+ ))
156
+ if AUTO_HEAL_TRIGGER_THRESHOLD < 1:
157
+ errors.append((
158
+ "E007",
159
+ f"AUTO_HEAL_TRIGGER_THRESHOLD must be >= 1 (current: {AUTO_HEAL_TRIGGER_THRESHOLD}).",
160
+ "Fix: export AUTO_HEAL_TRIGGER_THRESHOLD=2",
161
+ ))
162
+
163
+ if errors:
164
+ for code, what, fix in errors:
165
+ log.error(f"[{code}] {what} {fix}")
166
+ return False
167
+ return True
config/env_loader.py ADDED
@@ -0,0 +1,76 @@
1
+ import os
2
+ import shlex
3
+ from pathlib import Path
4
+
5
+
6
+ def resolve_dotenv_path(project_root: Path):
7
+ project_root = Path(project_root).resolve()
8
+ for candidate_root in [project_root, *project_root.parents]:
9
+ try:
10
+ entries = {path.name.lower(): path for path in candidate_root.iterdir()}
11
+ except OSError:
12
+ continue
13
+ for file_name in (".env", ".ENV"):
14
+ candidate = entries.get(file_name.lower())
15
+ if candidate and candidate.is_file():
16
+ return candidate
17
+ return project_root / ".env"
18
+
19
+
20
+ def _parse_env_line(line: str):
21
+ line = line.strip()
22
+ if not line or line.startswith("#"):
23
+ return None
24
+
25
+ if line.startswith("export "):
26
+ line = line[len("export ") :].strip()
27
+
28
+ if "=" not in line:
29
+ return None
30
+
31
+ key, value = line.split("=", 1)
32
+ key = key.strip()
33
+ value = value.strip()
34
+ if not key:
35
+ return None
36
+
37
+ if value and value[0] in {"'", '"'}:
38
+ try:
39
+ parsed = shlex.split(f"v={value}", posix=True)
40
+ value = parsed[0].split("=", 1)[1] if parsed else ""
41
+ except ValueError:
42
+ if len(value) >= 2 and value[0] == value[-1]:
43
+ value = value[1:-1]
44
+ else:
45
+ value = value.split(" #", 1)[0].rstrip()
46
+
47
+ return key, value
48
+
49
+
50
+ def _fallback_load_dotenv(dotenv_path: Path, override: bool = False) -> bool:
51
+ dotenv_path = Path(dotenv_path)
52
+ if not dotenv_path.exists():
53
+ return False
54
+
55
+ loaded = False
56
+ for raw_line in dotenv_path.read_text(encoding="utf-8").splitlines():
57
+ parsed = _parse_env_line(raw_line)
58
+ if not parsed:
59
+ continue
60
+
61
+ key, value = parsed
62
+ loaded = True
63
+ if key in os.environ and not override:
64
+ continue
65
+ os.environ[key] = value
66
+
67
+ return loaded
68
+
69
+
70
+ def safe_load_dotenv(dotenv_path: Path, override: bool = False) -> bool:
71
+ try:
72
+ from dotenv import load_dotenv
73
+
74
+ return bool(load_dotenv(dotenv_path=dotenv_path, override=override))
75
+ except ModuleNotFoundError:
76
+ return _fallback_load_dotenv(dotenv_path, override=override)