omni-engine 1.1.2.16__py2.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.
- omni/__init__.py +57 -0
- omni/agents/__init__.py +4 -0
- omni/agents/exploration.py +79 -0
- omni/agents/generator.py +58 -0
- omni/agents/healer.py +142 -0
- omni/agents/mcp_bridge.py +37 -0
- omni/agents/models.py +109 -0
- omni/agents/orchestrator.py +83 -0
- omni/agents/planner.py +212 -0
- omni/agents/script_writer.py +319 -0
- omni/agents/templates.py +205 -0
- omni/agents/testcase_writer.py +173 -0
- omni/cli/__init__.py +12 -0
- omni/cli/ai_cmd.py +165 -0
- omni/cli/api_cmd.py +220 -0
- omni/cli/config_cmd.py +28 -0
- omni/cli/main.py +268 -0
- omni/cli/manage_cmd.py +244 -0
- omni/cli/probe_cmd.py +160 -0
- omni/cli/pytest_cmd.py +152 -0
- omni/cli/spec_cmd.py +94 -0
- omni/cli/suite_cmd.py +457 -0
- omni/cli/sync_cmd.py +78 -0
- omni/cli/webai_cmd.py +162 -0
- omni/common/__init__.py +0 -0
- omni/common/case.py +783 -0
- omni/common/decorators.py +84 -0
- omni/common/emoji_msg.py +45 -0
- omni/common/exceptions.py +7 -0
- omni/common/log.py +7 -0
- omni/common/meta_class.py +1163 -0
- omni/common/reporter.py +7 -0
- omni/common/utils.py +171 -0
- omni/config/__init__.py +27 -0
- omni/config/conf_parser.py +409 -0
- omni/config/constants.py +64 -0
- omni/config/globals.py +14 -0
- omni/config/settings.ini +97 -0
- omni/config/unified_config.py +170 -0
- omni/config/version.py +115 -0
- omni/context/__init__.py +18 -0
- omni/context/call_tree_node.py +97 -0
- omni/context/context_vars.py +46 -0
- omni/context/db.py +248 -0
- omni/context/db_init.py +49 -0
- omni/context/global_context.py +481 -0
- omni/context/paths.py +151 -0
- omni/context/resource_manager.py +94 -0
- omni/context/runtime.py +85 -0
- omni/context/services.py +253 -0
- omni/context/webdriver_finder.py +55 -0
- omni/core/__init__.py +4 -0
- omni/core/ai/__init__.py +19 -0
- omni/core/ai/analyzer.py +105 -0
- omni/core/ai/data_generator.py +95 -0
- omni/core/ai/healing.py +103 -0
- omni/core/ai/openai_provider.py +241 -0
- omni/core/ai/provider.py +153 -0
- omni/core/ai/validators.py +42 -0
- omni/core/base.py +199 -0
- omni/core/evidence.py +169 -0
- omni/core/exceptions.py +211 -0
- omni/core/log.py +219 -0
- omni/core/registry.py +210 -0
- omni/core/reporter.py +50 -0
- omni/database/__init__.py +17 -0
- omni/database/mysql.py +49 -0
- omni/database/sqlite.py +265 -0
- omni/drivers/__init__.py +0 -0
- omni/drivers/api/__init__.py +0 -0
- omni/drivers/api/api_runner.py +768 -0
- omni/drivers/api/builtin/__init__.py +0 -0
- omni/drivers/api/builtin/comparators.py +242 -0
- omni/drivers/api/builtin/functions.py +190 -0
- omni/drivers/api/builtin_loader.py +266 -0
- omni/drivers/api/callback.py +222 -0
- omni/drivers/api/client.py +247 -0
- omni/drivers/api/data_loader/__init__.py +12 -0
- omni/drivers/api/data_loader/xmind_case_loader.py +305 -0
- omni/drivers/api/data_loader/yaml_case_loader.py +171 -0
- omni/drivers/api/engine/__init__.py +0 -0
- omni/drivers/api/engine/base.py +40 -0
- omni/drivers/api/engine/grpc_engine.py +192 -0
- omni/drivers/api/engine/http_engine.py +37 -0
- omni/drivers/api/engine/ws_engine.py +108 -0
- omni/drivers/api/executor.py +237 -0
- omni/drivers/api/ext/__init__.py +0 -0
- omni/drivers/api/ext/uploader.py +132 -0
- omni/drivers/api/grpc_client.py +192 -0
- omni/drivers/api/httpstat.py +95 -0
- omni/drivers/api/models.py +252 -0
- omni/drivers/api/orchestrator.py +156 -0
- omni/drivers/api/parser.py +641 -0
- omni/drivers/api/pytest_shim.py +86 -0
- omni/drivers/api/reporter.py +106 -0
- omni/drivers/api/response.py +365 -0
- omni/drivers/api/runner.py +11 -0
- omni/drivers/api/serializers/__init__.py +0 -0
- omni/drivers/api/serializers/result.py +201 -0
- omni/drivers/api/step.py +589 -0
- omni/drivers/api/step_executors/__init__.py +26 -0
- omni/drivers/api/step_executors/base.py +83 -0
- omni/drivers/api/step_executors/grpc_executor.py +106 -0
- omni/drivers/api/step_executors/script_executor.py +147 -0
- omni/drivers/api/step_executors/shell_executor.py +117 -0
- omni/drivers/api/step_executors/sleep_executor.py +68 -0
- omni/drivers/api/step_executors/sql_executor.py +159 -0
- omni/drivers/api/step_executors/ws_executor.py +100 -0
- omni/drivers/api/utils.py +52 -0
- omni/drivers/api/validator_extractor.py +66 -0
- omni/drivers/api/ws_client.py +108 -0
- omni/drivers/app/__init__.py +0 -0
- omni/drivers/app/app_driver.py +236 -0
- omni/drivers/app/app_runner.py +135 -0
- omni/drivers/app/exceptions.py +65 -0
- omni/drivers/cmd/__init__.py +0 -0
- omni/drivers/cmd/cmd_runner.py +150 -0
- omni/drivers/engine/__init__.py +61 -0
- omni/drivers/web/__init__.py +17 -0
- omni/drivers/web/base/__init__.py +0 -0
- omni/drivers/web/base/base_driver.py +13 -0
- omni/drivers/web/base/base_engine.py +459 -0
- omni/drivers/web/base/base_page.py +243 -0
- omni/drivers/web/base/driver_factory.py +217 -0
- omni/drivers/web/base/exceptions.py +79 -0
- omni/drivers/web/base/prober.py +785 -0
- omni/drivers/web/base/variable_resolver.py +54 -0
- omni/drivers/web/playwright/__init__.py +0 -0
- omni/drivers/web/playwright/playwright_driver.py +271 -0
- omni/drivers/web/playwright/playwright_engine.py +552 -0
- omni/drivers/web/selenium/__init__.py +0 -0
- omni/drivers/web/selenium/alert.py +64 -0
- omni/drivers/web/selenium/browser_manager.py +167 -0
- omni/drivers/web/selenium/keyboard.py +179 -0
- omni/drivers/web/selenium/locators.py +51 -0
- omni/drivers/web/selenium/selector.py +36 -0
- omni/drivers/web/selenium/selenium_driver.py +1030 -0
- omni/drivers/web/selenium/selenium_engine.py +439 -0
- omni/drivers/web/ui_assert.py +198 -0
- omni/drivers/web/web_runner.py +174 -0
- omni/integrations/__init__.py +12 -0
- omni/integrations/allure_custom/__init__.py +12 -0
- omni/integrations/allure_custom/apply_allure_custom.py +198 -0
- omni/integrations/allure_custom/settings.js +20 -0
- omni/integrations/jenkins_opt.py +92 -0
- omni/integrations/mysql_opt.py +219 -0
- omni/integrations/platform/__init__.py +9 -0
- omni/integrations/platform/client.py +119 -0
- omni/integrations/platform/manifest.py +85 -0
- omni/integrations/retry.py +172 -0
- omni/integrations/sqlalchemy_opt.py +119 -0
- omni/integrations/sqlite_opt.py +177 -0
- omni/integrations/ssh_mgr.py +359 -0
- omni/models/__init__.py +19 -0
- omni/models/conf.py +66 -0
- omni/models/emoji.py +136 -0
- omni/models/engine.py +100 -0
- omni/models/result.py +76 -0
- omni/models/testcase.py +61 -0
- omni/notification/__init__.py +12 -0
- omni/notification/mail.py +356 -0
- omni/notification/qw_chat.py +86 -0
- omni/omni_marks.py +24 -0
- omni/parser/__init__.py +18 -0
- omni/parser/testcase_scanner.py +385 -0
- omni/plugin/__init__.py +21 -0
- omni/plugin/__main__.py +69 -0
- omni/plugin/case_callback.py +151 -0
- omni/plugin/engine_plugin.py +336 -0
- omni/plugin/json_report.py +187 -0
- omni/plugin/template.py +224 -0
- omni/plugin_loader.py +70 -0
- omni/reporter/__init__.py +57 -0
- omni/reporter/allure/__init__.py +3 -0
- omni/reporter/allure/reporter.py +50 -0
- omni/reporter/base.py +73 -0
- omni/reporter/builtin/__init__.py +7 -0
- omni/reporter/builtin/model/__init__.py +43 -0
- omni/reporter/builtin/model/base.py +121 -0
- omni/reporter/builtin/model/message.py +46 -0
- omni/reporter/builtin/model/result.py +93 -0
- omni/reporter/builtin/model/statistics.py +55 -0
- omni/reporter/builtin/model/step.py +115 -0
- omni/reporter/builtin/model/suite.py +144 -0
- omni/reporter/builtin/model/testcase.py +77 -0
- omni/reporter/builtin/model/visitor.py +89 -0
- omni/reporter/builtin/output/__init__.py +243 -0
- omni/reporter/builtin/report/__init__.py +147 -0
- omni/reporter/builtin/report/html_writer.py +211 -0
- omni/reporter/builtin/report/json_writer.py +38 -0
- omni/reporter/builtin/report/xunit_writer.py +104 -0
- omni/reporter/builtin/reporter.py +191 -0
- omni/reporter/builtin/template.html +418 -0
- omni/reporter/log_to_html/__init__.py +51 -0
- omni/reporter/log_to_html/log.html +169 -0
- omni/reporter/log_to_html/log_template.html +193 -0
- omni/reporter/log_to_html/report.py +383 -0
- omni/reporter/manager.py +180 -0
- omni/reporter/noop/__init__.py +3 -0
- omni/reporter/noop/reporter.py +23 -0
- omni/reporter/registry.py +112 -0
- omni/reporter/utils.py +163 -0
- omni/spec/__init__.py +11 -0
- omni/spec/adapters/__init__.py +17 -0
- omni/spec/adapters/api_adapter.py +90 -0
- omni/spec/adapters/base.py +74 -0
- omni/spec/adapters/cli_adapter.py +20 -0
- omni/spec/adapters/grpc_adapter.py +44 -0
- omni/spec/adapters/registry.py +26 -0
- omni/spec/adapters/script_adapter.py +25 -0
- omni/spec/adapters/sleep_adapter.py +17 -0
- omni/spec/adapters/sql_adapter.py +98 -0
- omni/spec/adapters/web_adapter.py +334 -0
- omni/spec/adapters/ws_adapter.py +44 -0
- omni/spec/bridge.py +71 -0
- omni/spec/compiler.py +70 -0
- omni/spec/executor/__init__.py +9 -0
- omni/spec/executor/context.py +21 -0
- omni/spec/executor/step_executor.py +261 -0
- omni/spec/executor/tracker.py +46 -0
- omni/spec/executor/validator.py +204 -0
- omni/spec/generator/__init__.py +7 -0
- omni/spec/generator/base.py +15 -0
- omni/spec/generator/to_code.py +46 -0
- omni/spec/generator/to_spec.py +48 -0
- omni/spec/loader.py +79 -0
- omni/spec/models.py +66 -0
- omni/spec/variables.py +71 -0
- omni/utils/__init__.py +12 -0
- omni/utils/dsl_helper.py +224 -0
- omni/utils/file_helper.py +217 -0
- omni/utils/pkg_helper.py +83 -0
- omni/utils/singleton.py +28 -0
- omni/utils/util.py +408 -0
- omni_engine-1.1.2.16.dist-info/METADATA +1099 -0
- omni_engine-1.1.2.16.dist-info/RECORD +275 -0
- omni_engine-1.1.2.16.dist-info/WHEEL +6 -0
- omni_engine-1.1.2.16.dist-info/entry_points.txt +9 -0
- omni_engine-1.1.2.16.dist-info/top_level.txt +2 -0
- tests/__init__.py +12 -0
- tests/common/__init__.py +1 -0
- tests/common/test_common_utils.py +135 -0
- tests/common/test_decorator_class.py +33 -0
- tests/common/test_meta_class.py +321 -0
- tests/common/testcase/__init__.py +0 -0
- tests/common/testcase/test_repro_setup_class_cls.py +46 -0
- tests/context/__init__.py +1 -0
- tests/context/test_context_vars.py +55 -0
- tests/context/test_paths.py +87 -0
- tests/context/test_resource_manager.py +94 -0
- tests/context/test_services.py +134 -0
- tests/core/__init__.py +1 -0
- tests/core/ai/__init__.py +1 -0
- tests/core/ai/test_ai_analyzer.py +84 -0
- tests/core/ai/test_openai_provider.py +107 -0
- tests/core/test_engine_base.py +137 -0
- tests/core/test_engine_registry.py +123 -0
- tests/core/test_evidence.py +86 -0
- tests/core/test_reporter.py +84 -0
- tests/integrations/__init__.py +1 -0
- tests/integrations/test_platform_client.py +314 -0
- tests/parser/__init__.py +1 -0
- tests/parser/test_testcase_scanner.py +763 -0
- tests/plugin/__init__.py +1 -0
- tests/plugin/test_case_callback.py +145 -0
- tests/plugin/test_json_report_plugin.py +132 -0
- tests/plugin/test_plugin_template.py +78 -0
- tests/spec/__init__.py +1 -0
- tests/spec/test_behavior_matrix.py +100 -0
- tests/spec/test_bridge.py +84 -0
- tests/spec/test_spec_validator.py +116 -0
- tests/spec/test_step_executor_parallel.py +102 -0
- tests/spec/test_web_adapter_failure_log.py +90 -0
- tests/utils/__init__.py +1 -0
- tests/utils/test_util.py +167 -0
omni/__init__.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# omni-engine — 多协议自动化测试执行引擎
|
|
2
|
+
|
|
3
|
+
# ── 核心基类(直接从模块导入,避免循环加载) ──
|
|
4
|
+
from omni.core.base import BaseEngine, StepResult, AIAnalysisMeta
|
|
5
|
+
from omni.core.registry import EngineRegistry
|
|
6
|
+
|
|
7
|
+
# ── 兼容导出(旧代码使用,保持稳定) ──
|
|
8
|
+
from omni.context import GlobalContext
|
|
9
|
+
from omni.common.emoji_msg import EMsg
|
|
10
|
+
from omni.common.meta_class import CaseMetaClass, StepMetaClass
|
|
11
|
+
from omni.common.case import TestCaseBase
|
|
12
|
+
|
|
13
|
+
# ── 执行器(lazy 加载,通过 drivers/engine/__init__.py 触发注册) ──
|
|
14
|
+
from omni.drivers.engine import (
|
|
15
|
+
ApiRunner, WebRunner, CmdRunner, AppRunner,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
# ── Fluent API ──
|
|
19
|
+
from omni.drivers.api.step import RunRequest, Config
|
|
20
|
+
|
|
21
|
+
# ── AI 能力(可选) ──
|
|
22
|
+
try:
|
|
23
|
+
from omni.core.ai.validators import AIValidator as _AIValidator # type: ignore[assignment]
|
|
24
|
+
except ImportError:
|
|
25
|
+
class _AIValidator: # placeholder if AI not installed
|
|
26
|
+
"""AIValidator"""
|
|
27
|
+
pass
|
|
28
|
+
AIValidator: type = _AIValidator
|
|
29
|
+
|
|
30
|
+
# ── Spec 路径 ──
|
|
31
|
+
from omni.spec import SpecCompiler, StepExecutor
|
|
32
|
+
from omni.spec.executor.validator import SpecValidator
|
|
33
|
+
|
|
34
|
+
# ── 多模态证据 ──
|
|
35
|
+
from omni.core.evidence import EvidenceCollector
|
|
36
|
+
|
|
37
|
+
# ── 上下文工具 ──
|
|
38
|
+
from omni.context.paths import PathManager
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
# 执行器
|
|
42
|
+
"ApiRunner", "WebRunner", "CmdRunner", "AppRunner",
|
|
43
|
+
# 核心
|
|
44
|
+
"BaseEngine", "StepResult", "AIAnalysisMeta", "EngineRegistry", "TestCaseBase",
|
|
45
|
+
# Fluent API
|
|
46
|
+
"RunRequest", "Config",
|
|
47
|
+
# AI
|
|
48
|
+
"AIValidator",
|
|
49
|
+
# Spec
|
|
50
|
+
"SpecCompiler", "StepExecutor", "SpecValidator",
|
|
51
|
+
# 多模态
|
|
52
|
+
"EvidenceCollector",
|
|
53
|
+
# 路径
|
|
54
|
+
"PathManager",
|
|
55
|
+
# 兼容
|
|
56
|
+
"GlobalContext", "EMsg", "CaseMetaClass", "StepMetaClass",
|
|
57
|
+
]
|
omni/agents/__init__.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""探索公共能力(供 Planner / Prober 复用)
|
|
2
|
+
|
|
3
|
+
从 Planner 抽取: seed 步骤加载与执行、LLM 建议探索步骤转换。
|
|
4
|
+
"""
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Dict, List, Union
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def load_seed_actions(seed: Union[str, Path, List[Dict]]) -> List[Dict]:
|
|
12
|
+
"""加载 seed 步骤:内联列表 / .json 步骤列表 / .py 的 SEED_STEPS 变量"""
|
|
13
|
+
if isinstance(seed, list):
|
|
14
|
+
return seed
|
|
15
|
+
seed_path = Path(seed)
|
|
16
|
+
if not seed_path.exists():
|
|
17
|
+
raise FileNotFoundError(f"seed 文件不存在: {seed_path}")
|
|
18
|
+
if seed_path.suffix == ".json":
|
|
19
|
+
return json.loads(seed_path.read_text(encoding="utf-8"))
|
|
20
|
+
if seed_path.suffix == ".py":
|
|
21
|
+
ns: Dict[str, Any] = {}
|
|
22
|
+
exec(seed_path.read_text(encoding="utf-8"), ns) # noqa: S102 - seed 约定式
|
|
23
|
+
steps = ns.get("SEED_STEPS")
|
|
24
|
+
if not isinstance(steps, list):
|
|
25
|
+
raise ValueError(f"seed .py 文件 {seed_path} 需定义 SEED_STEPS 步骤列表")
|
|
26
|
+
return steps
|
|
27
|
+
raise ValueError(f"不支持的 seed 文件类型: {seed_path.suffix}(支持 .json / .py / 内联列表)")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def execute_seed_actions(engine, actions: List[Dict]) -> str:
|
|
31
|
+
"""在浏览器中顺序执行 seed 步骤,返回执行摘要文本"""
|
|
32
|
+
summary_lines = []
|
|
33
|
+
for step in actions:
|
|
34
|
+
_execute_action(engine, step)
|
|
35
|
+
summary_lines.append(
|
|
36
|
+
f"- {step.get('action', '?')}: {step.get('locator', step.get('url', step.get('text', '')))}"
|
|
37
|
+
)
|
|
38
|
+
return "\n".join(summary_lines)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _execute_action(engine, step: Dict) -> None:
|
|
42
|
+
action = step.get("action", "")
|
|
43
|
+
if action == "navigate":
|
|
44
|
+
engine.navigate(step["url"])
|
|
45
|
+
elif action == "type":
|
|
46
|
+
locator = step["locator"]
|
|
47
|
+
el = engine.find_element(locator)
|
|
48
|
+
engine.type(el, step.get("text", ""))
|
|
49
|
+
elif action == "click":
|
|
50
|
+
locator = step["locator"]
|
|
51
|
+
el = engine.find_element(locator)
|
|
52
|
+
engine.click(el)
|
|
53
|
+
elif action == "sleep":
|
|
54
|
+
time.sleep(float(step.get("seconds", 1)))
|
|
55
|
+
else:
|
|
56
|
+
raise ValueError(f"不支持的 seed 步骤 action: {action}")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def ask_exploration_steps(provider, request: str, prd_text: str,
|
|
60
|
+
pages: List[Dict], max_steps: int = 5) -> List[Dict]:
|
|
61
|
+
"""让 LLM 基于请求/PRD/当前快照输出建议的探索步骤(JSON 列表)"""
|
|
62
|
+
snapshot_text = json.dumps(pages, ensure_ascii=False)[:4000]
|
|
63
|
+
prompt = (
|
|
64
|
+
f"你是 Web 探索助手。用户请求: {request}\n"
|
|
65
|
+
f"PRD: {prd_text[:2000]}\n"
|
|
66
|
+
f"当前页面快照: {snapshot_text}\n\n"
|
|
67
|
+
f"请返回 JSON 数组,给出最多 {max_steps} 个后续探索步骤(如登录、点击导航、表单填写),"
|
|
68
|
+
'每步格式: {"action": "type|click|navigate", "locator": {"by": "id", "value": "..."}, '
|
|
69
|
+
'"text": "...", "url": "..."}。只返回 JSON,不要解释。'
|
|
70
|
+
)
|
|
71
|
+
raw = provider.llm_ask(prompt)
|
|
72
|
+
start, end = raw.find("["), raw.rfind("]")
|
|
73
|
+
if start == -1 or end == -1:
|
|
74
|
+
return []
|
|
75
|
+
try:
|
|
76
|
+
return json.loads(raw[start:end + 1])
|
|
77
|
+
except (json.JSONDecodeError, ValueError, TypeError):
|
|
78
|
+
# LLM 返回截断/非法 JSON → 返回 [] 不裸崩,由调用方明确报错提示改用 --seed(规格 §9.3/§10)
|
|
79
|
+
return []
|
omni/agents/generator.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""🎭 Generator Agent — 手工用例 → PO + testcase 自动化脚本(全 AI)
|
|
2
|
+
|
|
3
|
+
流程(废除模板渲染):
|
|
4
|
+
① Designer(TestcaseWriter): plan → testcases/*.md(手工执行用例)
|
|
5
|
+
② ScriptWriter: testcases → features/<page>_page.py(PO)+ testcase/<page>/test_<page>.py
|
|
6
|
+
"""
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import List
|
|
9
|
+
|
|
10
|
+
from loguru import logger
|
|
11
|
+
|
|
12
|
+
from omni.core.ai.provider import get_provider
|
|
13
|
+
from omni.agents.script_writer import ScriptWriter
|
|
14
|
+
from omni.agents.models import TestPlan
|
|
15
|
+
from omni.agents import templates
|
|
16
|
+
|
|
17
|
+
# 默认基类:TestCaseBase 提供框架执行规范(setup/teardown、断言、CaseMetaClass 注册)。
|
|
18
|
+
# 可传业务基类(如 demo.testcase.saucedemo.base.SauceDemo)以组合页面对象。
|
|
19
|
+
_DEFAULT_BASE_CLASS = "TestCaseBase"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Generator:
|
|
23
|
+
"""Generator:将 TestPlan 渲染为 testcases(Designer)再经 ScriptWriter 生成自动化脚本"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, provider=None):
|
|
26
|
+
"""__init__"""
|
|
27
|
+
self.provider = provider or get_provider()
|
|
28
|
+
|
|
29
|
+
def generate(self, plan: TestPlan, output_dir: str,
|
|
30
|
+
base_class: str = _DEFAULT_BASE_CLASS) -> List[str]:
|
|
31
|
+
"""生成自动化脚本(全 AI:Designer 出用例 → ScriptWriter 出 PO+testcase)
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
base_class: 生成的测试类继承的基类(默认 TestCaseBase,可传业务基类)
|
|
35
|
+
"""
|
|
36
|
+
out = Path(output_dir)
|
|
37
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
created = []
|
|
39
|
+
# 基础文件(conftest 注入 self.driver/om_web)— 执行必需;BasePage 由 PO 从框架 base.base_page 导入,无需生成
|
|
40
|
+
base_files = [
|
|
41
|
+
("conftest.py", templates.CONFTEST_TEMPLATE),
|
|
42
|
+
]
|
|
43
|
+
for rel, content in base_files:
|
|
44
|
+
target = out / rel
|
|
45
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
if not target.exists():
|
|
47
|
+
target.write_text(content, encoding="utf-8")
|
|
48
|
+
created.append(str(rel))
|
|
49
|
+
# ① Designer:plan → 手工用例行数据 + case.csv(不写 md)
|
|
50
|
+
from omni.agents.testcase_writer import TestcaseWriter
|
|
51
|
+
cases = TestcaseWriter(provider=self.provider).write(plan, str(out))
|
|
52
|
+
created.append("case.csv" if (out / "case.csv").exists() else "")
|
|
53
|
+
# ② ScriptWriter:场景行数据 → PO + testcase
|
|
54
|
+
if cases:
|
|
55
|
+
sw = ScriptWriter(provider=self.provider)
|
|
56
|
+
created += sw.write(cases, str(out), base_class=base_class)
|
|
57
|
+
logger.info(f"[webai] 生成完成: {len(created)} 个文件")
|
|
58
|
+
return [c for c in created if c]
|
omni/agents/healer.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""🎭 Healer Agent — 执行失败 → 分类 → 修复 → 重跑"""
|
|
2
|
+
import subprocess
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import List, Optional
|
|
6
|
+
|
|
7
|
+
from loguru import logger
|
|
8
|
+
|
|
9
|
+
from omni.core.ai.provider import get_provider
|
|
10
|
+
from omni.agents.models import HealReport
|
|
11
|
+
|
|
12
|
+
_PROMPT = Path(__file__).resolve().parent / "prompts" / "healer.md"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Healer:
|
|
16
|
+
"""Healer:执行测试、收集失败信号、AI 分类修复、重跑验证"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, provider=None, max_attempts: int = 3):
|
|
19
|
+
"""__init__"""
|
|
20
|
+
self.provider = provider or get_provider()
|
|
21
|
+
assert self.provider is not None, "AI Provider 不可用"
|
|
22
|
+
self.max_attempts = max_attempts
|
|
23
|
+
|
|
24
|
+
# ── 生成产物校验 + AI 自修复(对齐官方 healer guardrails) ──
|
|
25
|
+
|
|
26
|
+
def validate_generated(self, code: str, kind: str) -> bool:
|
|
27
|
+
"""校验 AI 生成产物:po 继承 BasePage;testcase 方法 test_ 且含 self.om_web"""
|
|
28
|
+
from omni.agents.script_writer import _validate_po, _validate_testcase
|
|
29
|
+
if kind == "po":
|
|
30
|
+
return _validate_po(code)
|
|
31
|
+
if kind == "testcase":
|
|
32
|
+
return _validate_testcase(code)
|
|
33
|
+
return False
|
|
34
|
+
|
|
35
|
+
def self_heal(self, code: str, kind: str, error: str = "") -> Optional[str]:
|
|
36
|
+
"""AI 自修复:校验不合格时让 LLM 修正,重试 ≤3 次"""
|
|
37
|
+
provider = self.provider
|
|
38
|
+
if provider is None:
|
|
39
|
+
return None
|
|
40
|
+
for attempt in range(1, self.max_attempts + 1):
|
|
41
|
+
if self.validate_generated(code, kind):
|
|
42
|
+
return code
|
|
43
|
+
try:
|
|
44
|
+
code = provider.llm_ask(
|
|
45
|
+
f"修复以下 {kind} 代码,使其通过校验"
|
|
46
|
+
f"(PO 继承 BasePage;testcase 方法 test_ 且含 self.om_web 调用)。\n"
|
|
47
|
+
f"错误: {error}\n代码:\n{code}\n只返回修正后的代码。"
|
|
48
|
+
)
|
|
49
|
+
except Exception as e:
|
|
50
|
+
logger.warning(f"[webai] healer 自修复 LLM 失败({e})")
|
|
51
|
+
return None
|
|
52
|
+
return code if self.validate_generated(code, kind) else None
|
|
53
|
+
|
|
54
|
+
def heal(self, test_path: str, cwd: str = "") -> HealReport:
|
|
55
|
+
"""执行单个测试文件并尝试修复
|
|
56
|
+
|
|
57
|
+
首版:测试通过 → status=healed;测试失败 → 交由 LLM 分类给出修复建议,
|
|
58
|
+
无法自动落地修复时标记 skipped(不静默通过)。
|
|
59
|
+
"""
|
|
60
|
+
test_file = Path(test_path)
|
|
61
|
+
name = test_file.name
|
|
62
|
+
logger.info(f"[webai] heal 开始执行: {name}")
|
|
63
|
+
run = self._run_pytest(test_file, cwd)
|
|
64
|
+
if run.returncode == 0:
|
|
65
|
+
logger.info(f"[webai] heal: {name} 测试通过(无需修复)")
|
|
66
|
+
return HealReport(test_name=name, category="none", original="",
|
|
67
|
+
fixed="", attempts=1, status="healed")
|
|
68
|
+
|
|
69
|
+
# 失败:收集信号
|
|
70
|
+
failure_text = run.stdout[-2000:] + run.stderr[-2000:]
|
|
71
|
+
logger.warning(f"[webai] heal: {name} 测试失败(rc={run.returncode}),收集失败信号")
|
|
72
|
+
provider = self.provider
|
|
73
|
+
if provider is None:
|
|
74
|
+
return HealReport(test_name=name, category="unknown", original="",
|
|
75
|
+
fixed="", attempts=1, status="skipped")
|
|
76
|
+
try:
|
|
77
|
+
system = _PROMPT.read_text(encoding="utf-8")
|
|
78
|
+
raw = provider.llm_ask(
|
|
79
|
+
f"{system}\n\n测试 {name} 失败,输出:\n{failure_text}\n\n"
|
|
80
|
+
"请回复 JSON: {\"category\": \"locator|timing|assertion|bug\", "
|
|
81
|
+
"\"suggestion\": \"...\"}"
|
|
82
|
+
)
|
|
83
|
+
category = self._classify(raw)
|
|
84
|
+
suggestion = self._extract_suggestion(raw)
|
|
85
|
+
logger.info(f"[webai] heal: {name} 分类={category} 建议={suggestion[:80]}")
|
|
86
|
+
except Exception as err: # noqa: BLE001 — LLM/提示词异常不可使 heal() 崩溃
|
|
87
|
+
logger.warning(f"[webai] heal: {name} LLM 不可用({err}),标记 skipped")
|
|
88
|
+
return HealReport(
|
|
89
|
+
test_name=name, category="unknown",
|
|
90
|
+
original=failure_text[:500], fixed=f"LLM unavailable: {err}",
|
|
91
|
+
attempts=1, status="skipped",
|
|
92
|
+
)
|
|
93
|
+
status = "skipped" if category != "bug" else "failed"
|
|
94
|
+
logger.info(f"[webai] heal: {name} 完成(status={status})")
|
|
95
|
+
return HealReport(
|
|
96
|
+
test_name=name, category=category,
|
|
97
|
+
original=failure_text[:500], fixed=suggestion,
|
|
98
|
+
attempts=1, status=status,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
def heal_all(self, tests_dir: str, cwd: str = "") -> List[HealReport]:
|
|
102
|
+
"""修复目录下所有失败测试"""
|
|
103
|
+
reports = []
|
|
104
|
+
for test_file in sorted(Path(tests_dir).glob("test_*.py")):
|
|
105
|
+
if test_file.name.startswith("test_") and test_file.name != "conftest.py":
|
|
106
|
+
reports.append(self.heal(str(test_file), cwd=cwd))
|
|
107
|
+
return reports
|
|
108
|
+
|
|
109
|
+
def _run_pytest(self, test_file: Path, cwd: str) -> subprocess.CompletedProcess:
|
|
110
|
+
python = sys.executable
|
|
111
|
+
# 绝对路径作为 pytest 目标:cwd 已设为 test_file.parent,
|
|
112
|
+
# 若再传相对路径会拼接成 cwd/相对路径 重复(Test file not found)
|
|
113
|
+
target = str(test_file.resolve())
|
|
114
|
+
try:
|
|
115
|
+
return subprocess.run(
|
|
116
|
+
[python, "-m", "pytest", target, "-q", "--no-header", "-x"],
|
|
117
|
+
capture_output=True, text=True, cwd=cwd or str(test_file.parent),
|
|
118
|
+
timeout=120,
|
|
119
|
+
)
|
|
120
|
+
except subprocess.TimeoutExpired as err:
|
|
121
|
+
# 超时视为失败(非零返回码),流向 LLM 分支
|
|
122
|
+
out = err.stdout or ""
|
|
123
|
+
if isinstance(out, bytes):
|
|
124
|
+
out = out.decode("utf-8", errors="replace")
|
|
125
|
+
err_out = err.stderr or ""
|
|
126
|
+
if isinstance(err_out, bytes):
|
|
127
|
+
err_out = err_out.decode("utf-8", errors="replace")
|
|
128
|
+
out += "\n[pytest timed out after 120s]"
|
|
129
|
+
return subprocess.CompletedProcess(
|
|
130
|
+
args=[python, "-m", "pytest", target],
|
|
131
|
+
returncode=-9, stdout=out, stderr=err_out,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
def _classify(self, raw: str) -> str:
|
|
135
|
+
import json, re
|
|
136
|
+
m = re.search(r'"category"\s*:\s*"(\w+)"', raw)
|
|
137
|
+
return m.group(1) if m else "unknown"
|
|
138
|
+
|
|
139
|
+
def _extract_suggestion(self, raw: str) -> str:
|
|
140
|
+
import json, re
|
|
141
|
+
m = re.search(r'"suggestion"\s*:\s*"([^"]*)"', raw)
|
|
142
|
+
return m.group(1) if m else raw[:200]
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""双通道桥接 — 将 agent 能力经 @playwright/mcp 暴露给外部 agent(VS Code/Claude 等)
|
|
2
|
+
|
|
3
|
+
外部接入方式:
|
|
4
|
+
python -m omni.agents.mcp_bridge start # 启动 playwright MCP 服务
|
|
5
|
+
python -m omni.agents.mcp_bridge config # 打印 .mcp.json 配置
|
|
6
|
+
"""
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from typing import Dict
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def build_mcp_config() -> Dict:
|
|
13
|
+
"""返回 .mcp.json 兼容的 playwright MCP 配置"""
|
|
14
|
+
return {
|
|
15
|
+
"mcpServers": {
|
|
16
|
+
"playwright": {
|
|
17
|
+
"command": "npx",
|
|
18
|
+
"args": ["-y", "@playwright/mcp@latest"],
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def start_mcp_server(port: int = 8931) -> subprocess.Popen:
|
|
25
|
+
"""启动 playwright MCP 服务子进程(独立进程,不影响内闭环)"""
|
|
26
|
+
return subprocess.Popen(
|
|
27
|
+
["npx", "-y", "@playwright/mcp@latest", f"--port={port}"],
|
|
28
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
if __name__ == "__main__":
|
|
33
|
+
if len(sys.argv) > 1 and sys.argv[1] == "start":
|
|
34
|
+
start_mcp_server()
|
|
35
|
+
else:
|
|
36
|
+
import json
|
|
37
|
+
print(json.dumps(build_mcp_config(), indent=2))
|
omni/agents/models.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""agent 数据模型 — 测试计划 / 用例 / 修复报告"""
|
|
2
|
+
import re
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Dict, List
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class TestCase:
|
|
9
|
+
"""单个测试场景(steps[i] 与 expected[i] 一一对应)"""
|
|
10
|
+
id: int
|
|
11
|
+
title: str
|
|
12
|
+
steps: List[str] = field(default_factory=list)
|
|
13
|
+
expected: List[str] = field(default_factory=list)
|
|
14
|
+
data: Dict = field(default_factory=dict)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class TestPlan:
|
|
19
|
+
"""Markdown 测试计划"""
|
|
20
|
+
name: str
|
|
21
|
+
scenarios: List[TestCase] = field(default_factory=list)
|
|
22
|
+
|
|
23
|
+
def to_markdown(self) -> str:
|
|
24
|
+
"""to_markdown"""
|
|
25
|
+
lines = [f"# {self.name}", ""]
|
|
26
|
+
for sc in self.scenarios:
|
|
27
|
+
lines.append(f"## Scenario {sc.id}: {sc.title}")
|
|
28
|
+
lines.append("")
|
|
29
|
+
# 步骤与期望一一对应:表格形式(每行 步骤 | 期望结果)
|
|
30
|
+
if sc.steps:
|
|
31
|
+
lines.append("| 步骤 | 期望结果 |")
|
|
32
|
+
lines.append("|------|----------|")
|
|
33
|
+
for i, step in enumerate(sc.steps, 1):
|
|
34
|
+
exp = sc.expected[i - 1] if i - 1 < len(sc.expected) else ""
|
|
35
|
+
lines.append(f"| {i}. {step} | {exp} |")
|
|
36
|
+
lines.append("")
|
|
37
|
+
return "\n".join(lines)
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def from_markdown(cls, text: str) -> "TestPlan":
|
|
41
|
+
"""from_markdown"""
|
|
42
|
+
lines = text.splitlines()
|
|
43
|
+
name = ""
|
|
44
|
+
for ln in lines:
|
|
45
|
+
if ln.startswith("# ") and not ln.startswith("## "):
|
|
46
|
+
name = ln[2:].strip()
|
|
47
|
+
break
|
|
48
|
+
scenarios = []
|
|
49
|
+
current = None
|
|
50
|
+
in_table = False
|
|
51
|
+
in_expected_block = False
|
|
52
|
+
for ln in lines:
|
|
53
|
+
stripped = ln.strip()
|
|
54
|
+
# 标准格式:## Scenario 1: 标题(提示词要求的唯一格式)
|
|
55
|
+
m = re.match(r"^## Scenario (\d+): (.+)$", stripped)
|
|
56
|
+
# 防御兼容:### 1. 标题(三级标题,旧提示词/LLM 可能输出)
|
|
57
|
+
m3 = re.match(r"^### (\d+)\.\s+(.+)$", stripped) if not m else None
|
|
58
|
+
matched = m or m3
|
|
59
|
+
if matched is not None:
|
|
60
|
+
current = TestCase(id=int(matched.group(1)),
|
|
61
|
+
title=matched.group(2).strip())
|
|
62
|
+
scenarios.append(current)
|
|
63
|
+
in_table = False
|
|
64
|
+
in_expected_block = False
|
|
65
|
+
continue
|
|
66
|
+
if current is None:
|
|
67
|
+
continue
|
|
68
|
+
# 表格行:| 1. 步骤 | 期望结果 |
|
|
69
|
+
if stripped.startswith("|") and stripped.endswith("|") and not stripped.startswith("|---"):
|
|
70
|
+
cols = [c.strip() for c in stripped.strip("|").split("|")]
|
|
71
|
+
if len(cols) >= 2 and not cols[0].startswith("步骤"):
|
|
72
|
+
step_text = re.sub(r"^\d+\.\s*", "", cols[0]).strip()
|
|
73
|
+
current.steps.append(step_text)
|
|
74
|
+
current.expected.append(cols[1].strip())
|
|
75
|
+
in_table = True
|
|
76
|
+
in_expected_block = False
|
|
77
|
+
continue
|
|
78
|
+
# 兼容旧格式:**Expected:** 区块
|
|
79
|
+
if stripped == "**Expected:**":
|
|
80
|
+
in_expected_block = True
|
|
81
|
+
continue
|
|
82
|
+
step_m = re.match(r"^(\d+)\.\s+(.+)$", stripped)
|
|
83
|
+
if step_m and not in_table and not in_expected_block:
|
|
84
|
+
current.steps.append(step_m.group(2).strip())
|
|
85
|
+
elif stripped.startswith("- ") and in_expected_block:
|
|
86
|
+
current.expected.append(stripped[2:].strip())
|
|
87
|
+
return cls(name=name, scenarios=scenarios)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass
|
|
91
|
+
class HealReport:
|
|
92
|
+
"""healer 修复报告(可审计)"""
|
|
93
|
+
test_name: str
|
|
94
|
+
category: str # locator / timing / assertion / bug
|
|
95
|
+
original: str
|
|
96
|
+
fixed: str
|
|
97
|
+
attempts: int
|
|
98
|
+
status: str # healed / skipped / failed
|
|
99
|
+
|
|
100
|
+
def to_markdown(self) -> str:
|
|
101
|
+
"""to_markdown"""
|
|
102
|
+
return (
|
|
103
|
+
f"## {self.test_name}\n\n"
|
|
104
|
+
f"- category: {self.category}\n"
|
|
105
|
+
f"- attempts: {self.attempts}\n"
|
|
106
|
+
f"- status: {self.status}\n"
|
|
107
|
+
f"- original: `{self.original}`\n"
|
|
108
|
+
f"- fixed: `{self.fixed}`\n"
|
|
109
|
+
)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""AgentOrchestrator — 三 agent 编排(single / sequential / loop)"""
|
|
2
|
+
import subprocess
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Dict, List
|
|
6
|
+
|
|
7
|
+
from loguru import logger
|
|
8
|
+
|
|
9
|
+
from omni.agents.planner import Planner
|
|
10
|
+
from omni.agents.generator import Generator
|
|
11
|
+
from omni.agents.healer import Healer
|
|
12
|
+
from omni.core.ai.provider import get_provider
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AgentOrchestrator:
|
|
16
|
+
"""编排 Planner → Generator → (pytest) → Healer"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, planner=None, generator=None, healer=None, provider=None):
|
|
19
|
+
"""__init__"""
|
|
20
|
+
self.provider = provider or get_provider()
|
|
21
|
+
self.planner = planner or Planner(engine=None, provider=self.provider)
|
|
22
|
+
self.generator = generator or Generator(provider=self.provider)
|
|
23
|
+
self.healer = healer or Healer(provider=self.provider)
|
|
24
|
+
|
|
25
|
+
def run_sequential(self, url: str, plan_path: str, tests_dir: str,
|
|
26
|
+
request: str = "", seed=None, prd: str = "") -> Dict:
|
|
27
|
+
"""planner → generator → pytest → healer 顺序执行
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
url: 被测应用 URL
|
|
31
|
+
plan_path: 测试计划保存路径(Markdown)
|
|
32
|
+
tests_dir: 生成测试输出目录
|
|
33
|
+
request: 明确请求(如"为访客结账生成一个计划"),透传给 planner
|
|
34
|
+
seed: 种子测试(内联步骤列表 / .json / .py 文件),预置环境
|
|
35
|
+
prd: 产品需求文档(文本或文件路径),作为 LLM 上下文
|
|
36
|
+
"""
|
|
37
|
+
# P6:探索引擎生命周期经 ResourceManager 托管(退出统一释放,防浏览器泄漏)
|
|
38
|
+
from omni.context.resource_manager import ResourceManager
|
|
39
|
+
|
|
40
|
+
with ResourceManager() as rm:
|
|
41
|
+
# 1. Planner 产出计划:engine 非空且计划不存在时先探索(含 seed/request/PRD)再保存;
|
|
42
|
+
# engine 为 None 时保持原行为——不探索,直接走 save_plan/LLM 兜底
|
|
43
|
+
plan_file = Path(plan_path)
|
|
44
|
+
if not plan_file.exists():
|
|
45
|
+
if self.planner.engine is not None:
|
|
46
|
+
if seed:
|
|
47
|
+
self.planner.run_seed(seed)
|
|
48
|
+
self.planner.explore(url, request=request, prd=prd)
|
|
49
|
+
self.planner.save_plan(plan_path)
|
|
50
|
+
logger.info(f"计划: {plan_path}")
|
|
51
|
+
|
|
52
|
+
# 2. Generator 生成测试
|
|
53
|
+
from omni.agents.models import TestPlan
|
|
54
|
+
plan = TestPlan.from_markdown(plan_file.read_text(encoding="utf-8"))
|
|
55
|
+
created = self.generator.generate(plan, tests_dir)
|
|
56
|
+
logger.info(f"生成测试: {created}")
|
|
57
|
+
|
|
58
|
+
# 禁止静默成功:计划无场景或零生成文件必须报错
|
|
59
|
+
if not plan.scenarios or not created:
|
|
60
|
+
logger.error(
|
|
61
|
+
f"计划未产出可执行场景: scenarios={len(plan.scenarios)}, "
|
|
62
|
+
f"created={len(created)}"
|
|
63
|
+
)
|
|
64
|
+
raise ValueError(
|
|
65
|
+
"计划未产出任何可执行测试场景"
|
|
66
|
+
f"(scenarios={len(plan.scenarios)}, created={len(created)})"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
# 3. 执行 pytest
|
|
70
|
+
python = sys.executable
|
|
71
|
+
run = subprocess.run(
|
|
72
|
+
[python, "-m", "pytest", tests_dir, "-q", "--no-header"],
|
|
73
|
+
capture_output=True, text=True,
|
|
74
|
+
)
|
|
75
|
+
logger.info(f"pytest exit={run.returncode}")
|
|
76
|
+
|
|
77
|
+
# 4. 失败 → Healer
|
|
78
|
+
heal_reports = []
|
|
79
|
+
if run.returncode != 0:
|
|
80
|
+
heal_reports = self.healer.heal_all(tests_dir)
|
|
81
|
+
logger.info(f"healer 报告: {len(heal_reports)} 条")
|
|
82
|
+
|
|
83
|
+
return {"plan": plan_path, "tests": created, "heal_reports": heal_reports}
|