chore: auto release commit (2026-06-15 17:32:47)
All checks were successful
技能自动化发布 / release (push) Successful in 7s
All checks were successful
技能自动化发布 / release (push) Successful in 7s
This commit is contained in:
125
examples/simulator_browser_rpa/README.md
Normal file
125
examples/simulator_browser_rpa/README.md
Normal file
@@ -0,0 +1,125 @@
|
||||
# Simulator Browser RPA Example
|
||||
|
||||
这是一个**仿真浏览器 RPA** 的可复制参考实现:在可控 sandbox 页面上,通过 Playwright 完成登录、表单填写、弹窗确认、结果解析。
|
||||
|
||||
本示例抽象自已跑通的「批量提交类」浏览器自动化 skill,但已去业务化,不包含真实银行/真实资金语义。
|
||||
|
||||
## 适合什么场景
|
||||
|
||||
- 自有 **仿真系统 / sandbox / training 页面**
|
||||
- 需要 **浏览器 RPA** 做端到端演示或回归
|
||||
- 表单较复杂:多步登录、Tab 切换、动态增行、PIN 弹窗、成功页解析
|
||||
- 需要 **adapter 分层**:mock(CI)与 simulator_rpa(半集成)可切换
|
||||
|
||||
## 不适合什么场景
|
||||
|
||||
- **真实第三方网站 + 高风控 + 人工验证码** → 优先看 [`../real_browser_rpa/README.md`](../real_browser_rpa/README.md)
|
||||
- 目标系统有稳定 **官方 API** → 不要硬写浏览器 RPA,后续参考 `real_api`
|
||||
- **不要把本示例的业务字段、selector、sandbox URL 原样复制**到你的 skill
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
simulator_browser_rpa/
|
||||
├── README.md
|
||||
├── sandbox/
|
||||
│ └── demo_app.html # 本地仿真页面(稳定 data-testid / name)
|
||||
├── scripts/
|
||||
│ ├── service/
|
||||
│ │ ├── browser_session.py # 浏览器启动(URL 仅 page.goto)
|
||||
│ │ ├── account_client.py # 账号来源集中封装
|
||||
│ │ ├── task_service.py # 编排层
|
||||
│ │ └── adapter/
|
||||
│ │ ├── base.py # BatchItem / BatchSubmitResult 契约
|
||||
│ │ ├── mock.py # mock/unit 档
|
||||
│ │ ├── simulator_rpa.py
|
||||
│ │ └── __init__.py # select_adapter
|
||||
│ └── util/
|
||||
│ ├── constants.py
|
||||
│ └── logging.py
|
||||
└── tests/
|
||||
├── test_adapter_dispatch.py
|
||||
├── test_simulator_rpa_unit.py
|
||||
└── test_stop_reason.py
|
||||
```
|
||||
|
||||
## 文件职责
|
||||
|
||||
| 文件 | 职责 |
|
||||
|---|---|
|
||||
| `browser_session.py` | 系统 Chrome/Edge + launch args 规范;支持 profile / 无 profile |
|
||||
| `account_client.py` | 示例从环境变量读账号;真实 skill 可在此集中对接 account-manager |
|
||||
| `adapter/base.py` | 数据契约与 `BatchAdapterBase` |
|
||||
| `adapter/mock.py` | 不启浏览器,供 unit/mock/CI |
|
||||
| `adapter/simulator_rpa.py` | 仿真 RPA 主流程 |
|
||||
| `task_service.py` | 校验输入 → 选 adapter → 提交 → 返回 summary |
|
||||
| `sandbox/demo_app.html` | 可控 DOM,配合 selector 稳定 |
|
||||
| `tests/` | dispatch、URL 不进 launch args、失败转结果 |
|
||||
|
||||
## 核心流程
|
||||
|
||||
1. `task_service.run_batch_submit(target, items)` 校验参数
|
||||
2. `select_adapter()` 按 `OPENCLAW_TEST_TARGET` 选择 mock 或 simulator RPA
|
||||
3. simulator 档:`pick_simulator_account()` → `set_credentials()` → `submit_batch()`
|
||||
4. RPA:登录两步 → 批量页 → 填表 → 提交 → PIN → 解析 `batch_id`
|
||||
5. 失败截图(有 `artifacts_dir` 时)→ 返回 `BatchSubmitResult`
|
||||
|
||||
## 复制到新 skill 时怎么做
|
||||
|
||||
**保留:**
|
||||
|
||||
- adapter 分层(base / mock / simulator_rpa / `select_adapter`)
|
||||
- `browser_session.py` 启动规范(**URL 不进 launch args**)
|
||||
- `account_client.py` 集中封装模式
|
||||
- 关键节点结构化日志 + 失败截图
|
||||
- 测试:dispatch、launch args、错误转 `BatchSubmitResult`
|
||||
|
||||
**替换:**
|
||||
|
||||
- `BatchItem` 字段与校验规则
|
||||
- `sandbox/demo_app.html` 或 `SIMULATOR_BASE_URL` 指向的页面
|
||||
- 全部 DOM selector(必须 F12 实测)
|
||||
- `task_service.py` 输入输出与 DB/任务日志
|
||||
- mock 返回字段、成功页 `batch_id` 解析逻辑
|
||||
|
||||
## 明确禁止
|
||||
|
||||
- 不要复制 `__pycache__`、`.pytest_cache`、`artifacts/`、`recordings/` 等生成物
|
||||
- **不要在技能侧**执行 `playwright install` / `pip install playwright`(Python 包由宿主共享 runtime 提供)
|
||||
- **不要把 URL 放进** `launch_persistent_context(args=[...])`
|
||||
- **不要在** `task_service.py` / RPA 主流程里散落 account-manager subprocess
|
||||
- **不要把真实生产系统**配置到 `simulator_rpa` 档位
|
||||
- 不要照抄 slug、logger 名、演示账号到生产 skill
|
||||
|
||||
## 依赖说明
|
||||
|
||||
- Python 3.10+
|
||||
- Python 包 `playwright`:由**宿主共享 runtime** 提供;技能侧不要写入 `requirements.txt`,不要 `pip install playwright`
|
||||
- 默认使用**系统 Chrome/Edge**,不使用 Playwright 内置 Chromium
|
||||
- 技能侧不要执行 `playwright install` / `playwright install chromium`
|
||||
- 本地独立开发若缺 Playwright Python 包,仅作开发机临时补装;生产由宿主解决
|
||||
- `jiangchang_skill_core.runtime_env.find_chrome_executable`(可选,用于查找系统浏览器)
|
||||
|
||||
## 环境变量(示例)
|
||||
|
||||
| 变量 | 说明 |
|
||||
|---|---|
|
||||
| `OPENCLAW_TEST_TARGET` | `mock`/`unit` → Mock;默认 → Simulator RPA |
|
||||
| `SIMULATOR_BASE_URL` | 仿真页 URL;默认本地 `sandbox/demo_app.html` 的 file URI |
|
||||
| `SIMULATOR_LOGIN_ID` / `PASSWORD` / `TOKEN_PIN` | 演示账号 |
|
||||
| `SIMULATOR_PROFILE_DIR` | 可选 persistent profile |
|
||||
| `OPENCLAW_BROWSER_HEADLESS` | `0` 默认有头;`1` 无头模式(CI 可用) |
|
||||
|
||||
## 运行测试
|
||||
|
||||
```bash
|
||||
cd examples/simulator_browser_rpa
|
||||
python -m pytest tests/ -v
|
||||
```
|
||||
|
||||
测试为 mock/纯函数,**不需要启动真实浏览器**。
|
||||
|
||||
## 当前状态
|
||||
|
||||
- 示例代码与 sandbox 页面已就绪,可作为仿真浏览器 RPA 复制起点。
|
||||
- 未接入完整 CLI、数据库与 entitlement。
|
||||
141
examples/simulator_browser_rpa/sandbox/demo_app.html
Normal file
141
examples/simulator_browser_rpa/sandbox/demo_app.html
Normal file
@@ -0,0 +1,141 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>仿真批量提交系统(示例 Sandbox)</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; margin: 24px; max-width: 960px; }
|
||||
section { margin-bottom: 24px; padding: 16px; border: 1px solid #ccc; }
|
||||
.hidden { display: none; }
|
||||
table { border-collapse: collapse; width: 100%; }
|
||||
td, th { border: 1px solid #ddd; padding: 8px; }
|
||||
dialog-like { display: block; border: 2px solid #333; padding: 16px; margin-top: 16px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>仿真业务系统 · 批量提交(示例)</h1>
|
||||
|
||||
<section id="login-step1-section">
|
||||
<h2>登录 · 第一步</h2>
|
||||
<form name="simLoginStep1" data-testid="login-step1">
|
||||
<label>账号 <input name="loginId" data-testid="login-id" /></label>
|
||||
<button type="submit">下一步</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section id="login-step2-section" class="hidden">
|
||||
<h2>登录 · 第二步</h2>
|
||||
<form name="simLoginStep2" data-testid="login-step2">
|
||||
<label>密码 <input name="password" type="password" data-testid="password" /></label>
|
||||
<label>验证码 <input name="captcha" data-testid="captcha" /></label>
|
||||
<button type="submit">登录</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section id="batch-section" class="hidden">
|
||||
<h2>批量提交</h2>
|
||||
<form name="simBatchForm" data-testid="batch-form">
|
||||
<label>来源账户
|
||||
<select name="fromAccountNo" data-testid="from-account">
|
||||
<option value="">请选择</option>
|
||||
<option value="acct-demo-001">演示账户 acct-demo-001</option>
|
||||
<option value="acct-demo-002">演示账户 acct-demo-002</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" id="tab-page-fill" data-testid="tab-page-fill">页面填写</button>
|
||||
<button type="button" id="btn-add-row" data-testid="add-row">新增一行</button>
|
||||
<table id="batch-table" data-testid="batch-table">
|
||||
<thead><tr><th>#</th><th>姓名</th><th>账号</th><th>备注</th><th>金额</th></tr></thead>
|
||||
<tbody id="batch-rows"></tbody>
|
||||
</table>
|
||||
<label>用途 <input name="purpose" data-testid="purpose" /></label>
|
||||
<button type="submit" data-testid="batch-submit">提交批次</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section id="success-section" class="hidden">
|
||||
<h2>提交成功</h2>
|
||||
<p data-testid="batch-id" id="batch-id-display"></p>
|
||||
<p data-testid="success-message">批次已受理,等待后续处理。</p>
|
||||
</section>
|
||||
|
||||
<div id="pin-dialog" class="hidden" role="dialog" aria-modal="true" data-testid="pin-dialog">
|
||||
<h3>确认 PIN</h3>
|
||||
<input type="password" inputmode="numeric" data-testid="pin-input" />
|
||||
<button type="button" id="pin-confirm" data-testid="pin-confirm">确认提交</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const DEMO_CAPTCHA = "0000";
|
||||
let rowCount = 0;
|
||||
|
||||
document.querySelector('form[name="simLoginStep1"]').addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
document.getElementById('login-step1-section').classList.add('hidden');
|
||||
document.getElementById('login-step2-section').classList.remove('hidden');
|
||||
});
|
||||
|
||||
document.querySelector('form[name="simLoginStep2"]').addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
const cap = document.querySelector('input[name="captcha"]').value;
|
||||
if (cap !== DEMO_CAPTCHA) {
|
||||
alert('验证码错误(演示值 0000)');
|
||||
return;
|
||||
}
|
||||
document.getElementById('login-step2-section').classList.add('hidden');
|
||||
document.getElementById('batch-section').classList.remove('hidden');
|
||||
ensureRow(1);
|
||||
});
|
||||
|
||||
document.getElementById('tab-page-fill').addEventListener('click', function () {
|
||||
document.getElementById('batch-table').scrollIntoView();
|
||||
});
|
||||
|
||||
document.getElementById('btn-add-row').addEventListener('click', function () {
|
||||
rowCount += 1;
|
||||
addRow(rowCount);
|
||||
});
|
||||
|
||||
function ensureRow(index) {
|
||||
if (!document.querySelector('tr[data-row-index="' + index + '"]')) {
|
||||
addRow(index);
|
||||
}
|
||||
rowCount = Math.max(rowCount, index);
|
||||
}
|
||||
|
||||
function addRow(index) {
|
||||
rowCount = Math.max(rowCount, index);
|
||||
const tbody = document.getElementById('batch-rows');
|
||||
const tr = document.createElement('tr');
|
||||
tr.setAttribute('data-row-index', String(index));
|
||||
tr.innerHTML =
|
||||
'<td>' + index + '</td>' +
|
||||
'<td><input name="name" data-testid="row-name-' + index + '" /></td>' +
|
||||
'<td><input name="account" data-testid="row-account-' + index + '" /></td>' +
|
||||
'<td><input name="note" data-testid="row-note-' + index + '" /></td>' +
|
||||
'<td><input name="amount" data-testid="row-amount-' + index + '" /></td>';
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
|
||||
document.querySelector('form[name="simBatchForm"]').addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
document.getElementById('pin-dialog').classList.remove('hidden');
|
||||
});
|
||||
|
||||
document.getElementById('pin-confirm').addEventListener('click', function () {
|
||||
const pin = document.querySelector('[data-testid="pin-input"]').value;
|
||||
if (!pin || pin.length < 4) {
|
||||
alert('请输入演示 PIN');
|
||||
return;
|
||||
}
|
||||
document.getElementById('pin-dialog').classList.add('hidden');
|
||||
const batchId = 'SIM-' + Date.now();
|
||||
document.getElementById('batch-section').classList.add('hidden');
|
||||
document.getElementById('success-section').classList.remove('hidden');
|
||||
document.getElementById('batch-id-display').textContent = batchId;
|
||||
document.getElementById('batch-id-display').dataset.batchId = batchId;
|
||||
window.location.hash = '#/batch/' + batchId;
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,40 @@
|
||||
"""仿真账号来源封装(示例级;真实 skill 可在此集中对接 account-manager)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from util.constants import (
|
||||
DEFAULT_DEMO_LOGIN_ID,
|
||||
DEFAULT_DEMO_PASSWORD,
|
||||
DEFAULT_DEMO_TOKEN_PIN,
|
||||
ENV_SIMULATOR_LOGIN_ID,
|
||||
ENV_SIMULATOR_PASSWORD,
|
||||
ENV_SIMULATOR_PROFILE_DIR,
|
||||
ENV_SIMULATOR_TOKEN_PIN,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SimulatorAccount:
|
||||
login_id: str
|
||||
password: str
|
||||
token_pin: str
|
||||
profile_dir: str = ""
|
||||
|
||||
|
||||
def pick_simulator_account() -> SimulatorAccount:
|
||||
"""
|
||||
示例账号来源:优先读环境变量,否则返回内置 demo 账号。
|
||||
|
||||
真实 skill 若依赖 account-manager,应把 subprocess / sibling_bridge 调用
|
||||
集中在本模块,不要散落到 task_service 或 RPA 主流程中。
|
||||
"""
|
||||
return SimulatorAccount(
|
||||
login_id=(os.getenv(ENV_SIMULATOR_LOGIN_ID) or DEFAULT_DEMO_LOGIN_ID).strip(),
|
||||
password=(os.getenv(ENV_SIMULATOR_PASSWORD) or DEFAULT_DEMO_PASSWORD).strip(),
|
||||
token_pin=(os.getenv(ENV_SIMULATOR_TOKEN_PIN) or DEFAULT_DEMO_TOKEN_PIN).strip(),
|
||||
profile_dir=(os.getenv(ENV_SIMULATOR_PROFILE_DIR) or "").strip(),
|
||||
)
|
||||
@@ -0,0 +1,40 @@
|
||||
"""adapter 工厂:按 OPENCLAW_TEST_TARGET 选择实现。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
from service.adapter.base import BatchAdapterBase, BatchItem, BatchSubmitResult
|
||||
from service.adapter.mock import MockBatchAdapter
|
||||
from service.adapter.simulator_rpa import SimulatorBrowserRpaAdapter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
__all__ = [
|
||||
"select_adapter",
|
||||
"BatchAdapterBase",
|
||||
"BatchItem",
|
||||
"BatchSubmitResult",
|
||||
"MockBatchAdapter",
|
||||
"SimulatorBrowserRpaAdapter",
|
||||
]
|
||||
|
||||
|
||||
def select_adapter(artifacts_dir: Optional[str] = None) -> BatchAdapterBase:
|
||||
target = os.environ.get("OPENCLAW_TEST_TARGET", "").strip().lower()
|
||||
|
||||
if target in ("mock", "unit"):
|
||||
logger.info("target '%s': MockBatchAdapter", target)
|
||||
return MockBatchAdapter()
|
||||
|
||||
if target == "real_rpa":
|
||||
logger.warning("target real_rpa 未实现,回退 MockBatchAdapter")
|
||||
return MockBatchAdapter()
|
||||
|
||||
if target and target != "simulator_rpa":
|
||||
logger.warning("未知 target '%s',回退 simulator_rpa", target)
|
||||
|
||||
logger.info("target '%s': SimulatorBrowserRpaAdapter", target or "(unset=default)")
|
||||
return SimulatorBrowserRpaAdapter(artifacts_dir=artifacts_dir)
|
||||
@@ -0,0 +1,32 @@
|
||||
"""批量提交 adapter 基类与数据契约。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class BatchItem:
|
||||
row_index: int
|
||||
name: str
|
||||
account: str
|
||||
amount: float
|
||||
note: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class BatchSubmitResult:
|
||||
ok: bool
|
||||
batch_id: Optional[str]
|
||||
submitted_count: int
|
||||
submitted_amount: float
|
||||
error_msg: Optional[str]
|
||||
artifacts: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
class BatchAdapterBase:
|
||||
name: str = "base"
|
||||
|
||||
def submit_batch(self, target: str, items: List[BatchItem]) -> BatchSubmitResult:
|
||||
raise NotImplementedError
|
||||
@@ -0,0 +1,36 @@
|
||||
"""mock / unit 档位:不启动浏览器。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import List
|
||||
|
||||
from service.adapter.base import BatchAdapterBase, BatchItem, BatchSubmitResult
|
||||
|
||||
|
||||
class MockBatchAdapter(BatchAdapterBase):
|
||||
name = "mock"
|
||||
|
||||
def submit_batch(self, target: str, items: List[BatchItem]) -> BatchSubmitResult:
|
||||
del target
|
||||
if not items:
|
||||
return BatchSubmitResult(
|
||||
ok=False,
|
||||
batch_id=None,
|
||||
submitted_count=0,
|
||||
submitted_amount=0.0,
|
||||
error_msg="items 为空",
|
||||
artifacts={"adapter": self.name},
|
||||
)
|
||||
|
||||
time.sleep(0.05)
|
||||
total = sum(it.amount for it in items)
|
||||
fake_batch_id = f"MOCK-{int(time.time())}"
|
||||
return BatchSubmitResult(
|
||||
ok=True,
|
||||
batch_id=fake_batch_id,
|
||||
submitted_count=len(items),
|
||||
submitted_amount=total,
|
||||
error_msg=None,
|
||||
artifacts={"adapter": self.name},
|
||||
)
|
||||
@@ -0,0 +1,340 @@
|
||||
"""仿真浏览器 RPA:操作 sandbox/demo_app.html 完成批量提交。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import time
|
||||
from typing import List, Optional
|
||||
|
||||
from service.adapter.base import BatchAdapterBase, BatchItem, BatchSubmitResult
|
||||
from service.browser_session import browser_session, find_chrome_executable
|
||||
from util.constants import (
|
||||
DEFAULT_DEMO_CAPTCHA,
|
||||
DEFAULT_DEMO_LOGIN_ID,
|
||||
DEFAULT_DEMO_PASSWORD,
|
||||
DEFAULT_DEMO_TOKEN_PIN,
|
||||
DEFAULT_TIMEOUT_MS,
|
||||
LOGIN_NAVIGATE_TIMEOUT_MS,
|
||||
SUBMIT_NAVIGATE_TIMEOUT_MS,
|
||||
resolve_simulator_base_url,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_PAUSE_MIN_MS = 500
|
||||
_PAUSE_MAX_MS = 2000
|
||||
|
||||
|
||||
class RpaError(Exception):
|
||||
def __init__(self, message: str, screenshot_path: Optional[str] = None):
|
||||
super().__init__(message)
|
||||
self.screenshot_path = screenshot_path
|
||||
|
||||
|
||||
class SimulatorBrowserRpaAdapter(BatchAdapterBase):
|
||||
name = "simulator_browser_rpa"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: Optional[str] = None,
|
||||
headless: Optional[bool] = None,
|
||||
artifacts_dir: Optional[str] = None,
|
||||
) -> None:
|
||||
self.base_url = (base_url or resolve_simulator_base_url()).rstrip("/")
|
||||
if headless is None:
|
||||
env = os.environ.get("OPENCLAW_BROWSER_HEADLESS", "").strip().lower()
|
||||
self.headless = env in ("1", "true", "yes")
|
||||
else:
|
||||
self.headless = headless
|
||||
self.artifacts_dir = artifacts_dir
|
||||
|
||||
self._login_id: str = DEFAULT_DEMO_LOGIN_ID
|
||||
self._password: str = DEFAULT_DEMO_PASSWORD
|
||||
self._token_pin: str = DEFAULT_DEMO_TOKEN_PIN
|
||||
self._profile_dir: Optional[str] = None
|
||||
|
||||
def set_credentials(
|
||||
self,
|
||||
login_id: str,
|
||||
password: str,
|
||||
token_pin: str,
|
||||
profile_dir: str,
|
||||
) -> None:
|
||||
self._login_id = login_id
|
||||
self._password = password
|
||||
self._token_pin = token_pin
|
||||
stripped = (profile_dir or "").strip()
|
||||
self._profile_dir = stripped or None
|
||||
|
||||
def submit_batch(self, target: str, items: List[BatchItem]) -> BatchSubmitResult:
|
||||
try:
|
||||
from playwright.sync_api import sync_playwright # noqa: F401
|
||||
except ImportError:
|
||||
return BatchSubmitResult(
|
||||
ok=False,
|
||||
batch_id=None,
|
||||
submitted_count=0,
|
||||
submitted_amount=0.0,
|
||||
error_msg=(
|
||||
"playwright Python 包不可用。"
|
||||
"生产/宿主运行由共享 runtime 提供;技能侧不要 pip install playwright。"
|
||||
),
|
||||
artifacts={"adapter": self.name},
|
||||
)
|
||||
|
||||
if not items:
|
||||
return BatchSubmitResult(
|
||||
ok=False,
|
||||
batch_id=None,
|
||||
submitted_count=0,
|
||||
submitted_amount=0.0,
|
||||
error_msg="items 为空",
|
||||
artifacts={"adapter": self.name},
|
||||
)
|
||||
|
||||
if not find_chrome_executable():
|
||||
return BatchSubmitResult(
|
||||
ok=False,
|
||||
batch_id=None,
|
||||
submitted_count=0,
|
||||
submitted_amount=0.0,
|
||||
error_msg="未检测到 Chrome 或 Edge 浏览器,请安装系统浏览器后重试。",
|
||||
artifacts={"adapter": self.name},
|
||||
)
|
||||
|
||||
start_url = self.base_url
|
||||
try:
|
||||
with browser_session(
|
||||
start_url,
|
||||
profile_dir=self._profile_dir,
|
||||
headless=self.headless,
|
||||
) as page:
|
||||
try:
|
||||
logger.info("rpa_login_start url=%s", start_url)
|
||||
self._login(page)
|
||||
logger.info("rpa_login_done")
|
||||
|
||||
self._goto_batch_page(page)
|
||||
logger.info("rpa_batch_page_ready")
|
||||
|
||||
self._fill_batch_form(page, target, items)
|
||||
logger.info("rpa_batch_form_filled rows=%s", len(items))
|
||||
|
||||
batch_id = self._submit_and_get_batch_id(page)
|
||||
logger.info("rpa_submit_success batch_id=%s", batch_id)
|
||||
|
||||
total = sum(it.amount for it in items)
|
||||
return BatchSubmitResult(
|
||||
ok=True,
|
||||
batch_id=batch_id,
|
||||
submitted_count=len(items),
|
||||
submitted_amount=total,
|
||||
error_msg=None,
|
||||
artifacts={"adapter": self.name},
|
||||
)
|
||||
except RpaError as exc:
|
||||
logger.error("rpa_failed: %s", exc)
|
||||
arts = {"adapter": self.name}
|
||||
if exc.screenshot_path:
|
||||
arts["screenshot"] = exc.screenshot_path
|
||||
return BatchSubmitResult(
|
||||
ok=False,
|
||||
batch_id=None,
|
||||
submitted_count=0,
|
||||
submitted_amount=0.0,
|
||||
error_msg=str(exc),
|
||||
artifacts=arts,
|
||||
)
|
||||
except Exception as exc:
|
||||
sp = self._safe_screenshot(page, "unexpected_error")
|
||||
logger.exception("rpa_unexpected: %s", exc)
|
||||
arts = {"adapter": self.name}
|
||||
if sp:
|
||||
arts["screenshot"] = sp
|
||||
return BatchSubmitResult(
|
||||
ok=False,
|
||||
batch_id=None,
|
||||
submitted_count=0,
|
||||
submitted_amount=0.0,
|
||||
error_msg=f"未预期异常:{type(exc).__name__}: {exc}",
|
||||
artifacts=arts,
|
||||
)
|
||||
except Exception as outer:
|
||||
return BatchSubmitResult(
|
||||
ok=False,
|
||||
batch_id=None,
|
||||
submitted_count=0,
|
||||
submitted_amount=0.0,
|
||||
error_msg=f"启动浏览器失败:{outer}",
|
||||
artifacts={"adapter": self.name},
|
||||
)
|
||||
|
||||
def _pause(self, min_ms: int = _PAUSE_MIN_MS, max_ms: int = _PAUSE_MAX_MS) -> None:
|
||||
time.sleep(random.uniform(min_ms, max_ms) / 1000.0)
|
||||
|
||||
def _login(self, page) -> None:
|
||||
try:
|
||||
page.wait_for_selector('form[name="simLoginStep1"]', timeout=LOGIN_NAVIGATE_TIMEOUT_MS)
|
||||
self._pause()
|
||||
page.fill('form[name="simLoginStep1"] input[name="loginId"]', self._login_id)
|
||||
self._pause()
|
||||
page.click('form[name="simLoginStep1"] button[type="submit"]')
|
||||
except Exception as exc:
|
||||
sp = self._safe_screenshot(page, "login_step1_failed")
|
||||
raise RpaError(f"登录第一步失败:{exc}", screenshot_path=sp) from exc
|
||||
|
||||
try:
|
||||
page.wait_for_selector('form[name="simLoginStep2"]', timeout=DEFAULT_TIMEOUT_MS)
|
||||
self._pause()
|
||||
page.fill('form[name="simLoginStep2"] input[name="password"]', self._password)
|
||||
page.fill('form[name="simLoginStep2"] input[name="captcha"]', DEFAULT_DEMO_CAPTCHA)
|
||||
self._pause()
|
||||
page.click('form[name="simLoginStep2"] button[type="submit"]')
|
||||
except Exception as exc:
|
||||
sp = self._safe_screenshot(page, "login_step2_failed")
|
||||
raise RpaError(f"登录第二步失败:{exc}", screenshot_path=sp) from exc
|
||||
|
||||
try:
|
||||
page.wait_for_selector('form[name="simBatchForm"]', timeout=LOGIN_NAVIGATE_TIMEOUT_MS)
|
||||
except Exception as exc:
|
||||
sp = self._safe_screenshot(page, "login_navigate_failed")
|
||||
raise RpaError(f"登录后未进入批量提交页:{exc}", screenshot_path=sp) from exc
|
||||
|
||||
def _goto_batch_page(self, page) -> None:
|
||||
try:
|
||||
page.wait_for_selector('form[name="simBatchForm"]', timeout=DEFAULT_TIMEOUT_MS)
|
||||
self._pause()
|
||||
except Exception as exc:
|
||||
sp = self._safe_screenshot(page, "goto_batch_failed")
|
||||
raise RpaError(f"进入批量提交页失败:{exc}", screenshot_path=sp) from exc
|
||||
|
||||
def _select_target_account(self, page, target: str) -> None:
|
||||
sel = page.locator('select[name="fromAccountNo"]')
|
||||
sel.wait_for(timeout=DEFAULT_TIMEOUT_MS)
|
||||
n = sel.locator("option").count()
|
||||
matched_value: Optional[str] = None
|
||||
first_value: Optional[str] = None
|
||||
for i in range(n):
|
||||
opt = sel.locator("option").nth(i)
|
||||
val = opt.get_attribute("value") or ""
|
||||
if not val.strip():
|
||||
continue
|
||||
label_txt = opt.inner_text()
|
||||
if first_value is None:
|
||||
first_value = val
|
||||
if target.strip() and target.strip() in label_txt:
|
||||
matched_value = val
|
||||
break
|
||||
pick = matched_value or first_value
|
||||
if not pick:
|
||||
raise RpaError("来源账户下拉无可选项")
|
||||
if not matched_value and target.strip():
|
||||
logger.warning("target '%s' 未匹配选项,回退 value=%s", target, pick)
|
||||
sel.select_option(value=pick)
|
||||
self._pause()
|
||||
|
||||
def _fill_batch_form(self, page, target: str, items: List[BatchItem]) -> None:
|
||||
try:
|
||||
page.get_by_role("button", name=re.compile(r"页面填写")).click()
|
||||
except Exception as exc:
|
||||
sp = self._safe_screenshot(page, "tab_switch_failed")
|
||||
raise RpaError(f"切换到页面填写失败:{exc}", screenshot_path=sp) from exc
|
||||
|
||||
try:
|
||||
self._select_target_account(page, target)
|
||||
except RpaError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
sp = self._safe_screenshot(page, "select_account_failed")
|
||||
raise RpaError(f"选择来源账户失败:{exc}", screenshot_path=sp) from exc
|
||||
|
||||
try:
|
||||
existing = page.locator("tr[data-row-index]").count()
|
||||
need_more = max(0, len(items) - existing)
|
||||
add_btn = page.get_by_role("button", name=re.compile(r"新增一行"))
|
||||
for _ in range(need_more):
|
||||
self._pause(400, 900)
|
||||
add_btn.click()
|
||||
except Exception as exc:
|
||||
sp = self._safe_screenshot(page, "add_rows_failed")
|
||||
raise RpaError(f"新增明细行失败:{exc}", screenshot_path=sp) from exc
|
||||
|
||||
for it in items:
|
||||
try:
|
||||
row = page.locator(f'tr[data-row-index="{it.row_index}"]')
|
||||
row.wait_for(timeout=DEFAULT_TIMEOUT_MS)
|
||||
self._pause(300, 800)
|
||||
row.locator('input[name="name"]').fill(it.name)
|
||||
row.locator('input[name="account"]').fill(it.account)
|
||||
row.locator('input[name="note"]').fill(it.note or "")
|
||||
row.locator('input[name="amount"]').fill(f"{it.amount}")
|
||||
except Exception as exc:
|
||||
sp = self._safe_screenshot(page, f"fill_row_{it.row_index}_failed")
|
||||
raise RpaError(f"填第 {it.row_index} 行失败:{exc}", screenshot_path=sp) from exc
|
||||
|
||||
try:
|
||||
page.fill('input[name="purpose"]', "仿真批量提交示例")
|
||||
except Exception as exc:
|
||||
logger.warning("填写用途失败(非致命):%s", exc)
|
||||
|
||||
def _submit_and_get_batch_id(self, page) -> str:
|
||||
try:
|
||||
self._pause(800, 1500)
|
||||
page.locator('form[name="simBatchForm"] button[type="submit"]').click()
|
||||
except Exception as exc:
|
||||
sp = self._safe_screenshot(page, "click_submit_failed")
|
||||
raise RpaError(f"点击提交失败:{exc}", screenshot_path=sp) from exc
|
||||
|
||||
dlg_sel = 'div[role="dialog"][aria-modal="true"]'
|
||||
try:
|
||||
page.wait_for_selector(dlg_sel, timeout=DEFAULT_TIMEOUT_MS)
|
||||
except Exception as exc:
|
||||
sp = self._safe_screenshot(page, "pin_dialog_not_open")
|
||||
raise RpaError(f"PIN 弹窗未出现:{exc}", screenshot_path=sp) from exc
|
||||
|
||||
try:
|
||||
pin_input = page.locator(f'{dlg_sel} input[type="password"][inputmode="numeric"]')
|
||||
self._pause(500, 1200)
|
||||
pin_input.fill(self._token_pin)
|
||||
except Exception as exc:
|
||||
sp = self._safe_screenshot(page, "fill_pin_failed")
|
||||
raise RpaError(f"填写 PIN 失败:{exc}", screenshot_path=sp) from exc
|
||||
|
||||
try:
|
||||
page.locator(dlg_sel).get_by_role("button", name=re.compile(r"确认提交")).click()
|
||||
except Exception as exc:
|
||||
sp = self._safe_screenshot(page, "click_confirm_failed")
|
||||
raise RpaError(f"确认提交失败:{exc}", screenshot_path=sp) from exc
|
||||
|
||||
try:
|
||||
page.wait_for_selector('[data-testid="batch-id"]', timeout=SUBMIT_NAVIGATE_TIMEOUT_MS)
|
||||
except Exception as exc:
|
||||
sp = self._safe_screenshot(page, "success_area_not_visible")
|
||||
raise RpaError(f"提交后未出现成功区域:{exc}", screenshot_path=sp) from exc
|
||||
|
||||
batch_id = page.locator('[data-testid="batch-id"]').get_attribute("data-batch-id") or ""
|
||||
if not batch_id:
|
||||
batch_id = page.locator('[data-testid="batch-id"]').inner_text().strip()
|
||||
url = page.url
|
||||
if not batch_id and "#/batch/" in url:
|
||||
batch_id = url.split("#/batch/", 1)[1].split("?")[0].split("#")[0].strip("/")
|
||||
if not batch_id:
|
||||
sp = self._safe_screenshot(page, "batch_id_empty")
|
||||
raise RpaError(f"无法解析 batch_id,url={url}", screenshot_path=sp)
|
||||
return batch_id
|
||||
|
||||
def _safe_screenshot(self, page, tag: str) -> Optional[str]:
|
||||
if not self.artifacts_dir:
|
||||
return None
|
||||
try:
|
||||
os.makedirs(self.artifacts_dir, exist_ok=True)
|
||||
path = os.path.join(self.artifacts_dir, f"{tag}_{int(time.time())}.png")
|
||||
page.screenshot(path=path, full_page=True)
|
||||
logger.info("screenshot_saved path=%s", path)
|
||||
return path
|
||||
except Exception as exc:
|
||||
logger.warning("screenshot_failed tag=%s err=%s", tag, exc)
|
||||
return None
|
||||
@@ -0,0 +1,138 @@
|
||||
"""浏览器会话启动(系统 Chrome/Edge;URL 仅通过 page.goto 打开)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from contextlib import contextmanager
|
||||
from typing import Any, Generator, Iterator, List, Optional, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_PAGE_TIMEOUT_MS = 15_000
|
||||
GOTO_TIMEOUT_MS = 60_000
|
||||
|
||||
CHROME_LAUNCH_ARGS: List[str] = [
|
||||
"--start-maximized",
|
||||
"--disable-blink-features=AutomationControlled",
|
||||
]
|
||||
|
||||
STEALTH_INIT_SCRIPT = """
|
||||
(() => {
|
||||
try { Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); } catch (e) {}
|
||||
})();
|
||||
"""
|
||||
|
||||
|
||||
def chrome_launch_args() -> List[str]:
|
||||
return list(CHROME_LAUNCH_ARGS)
|
||||
|
||||
|
||||
def _headless_from_env() -> bool:
|
||||
v = (os.getenv("OPENCLAW_BROWSER_HEADLESS") or "0").strip().lower()
|
||||
return v in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
def _find_chrome_executable() -> str | None:
|
||||
try:
|
||||
from jiangchang_skill_core.runtime_env import find_chrome_executable
|
||||
|
||||
chrome = find_chrome_executable()
|
||||
if chrome and os.path.isfile(chrome):
|
||||
return chrome
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
candidates = [
|
||||
os.path.expandvars(r"%ProgramFiles%\Google\Chrome\Application\chrome.exe"),
|
||||
os.path.expandvars(r"%ProgramFiles(x86)%\Google\Chrome\Application\chrome.exe"),
|
||||
os.path.expandvars(r"%ProgramFiles%\Microsoft\Edge\Application\msedge.exe"),
|
||||
os.path.expandvars(r"%ProgramFiles(x86)%\Microsoft\Edge\Application\msedge.exe"),
|
||||
]
|
||||
for path in candidates:
|
||||
if path and os.path.isfile(path):
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
def _clear_node_options() -> None:
|
||||
os.environ.pop("NODE_OPTIONS", None)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def browser_session(
|
||||
start_url: str,
|
||||
*,
|
||||
profile_dir: Optional[str] = None,
|
||||
headless: Optional[bool] = None,
|
||||
) -> Generator[Any, None, None]:
|
||||
"""
|
||||
同步 Playwright 浏览器会话。
|
||||
|
||||
- Playwright Python 包由宿主共享 runtime 提供;技能侧不要 playwright install。
|
||||
- launch args 仅放 Chrome 参数,不含 URL。
|
||||
- 流程:launch → new_page → goto(start_url)
|
||||
"""
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
_clear_node_options()
|
||||
hl = headless if headless is not None else _headless_from_env()
|
||||
chrome = _find_chrome_executable()
|
||||
if not chrome:
|
||||
raise RuntimeError(
|
||||
"未检测到 Chrome 或 Edge 浏览器,请安装系统浏览器后重试。"
|
||||
)
|
||||
|
||||
args = chrome_launch_args()
|
||||
logger.info(
|
||||
"browser_launch chrome=%s profile_dir=%s start_url=%s args=%s headless=%s",
|
||||
chrome,
|
||||
profile_dir or "",
|
||||
start_url,
|
||||
args,
|
||||
hl,
|
||||
)
|
||||
|
||||
browser_holder = None
|
||||
context = None
|
||||
with sync_playwright() as pw:
|
||||
try:
|
||||
if profile_dir:
|
||||
context = pw.chromium.launch_persistent_context(
|
||||
user_data_dir=profile_dir,
|
||||
headless=hl,
|
||||
executable_path=chrome,
|
||||
locale="zh-CN",
|
||||
no_viewport=True,
|
||||
args=args,
|
||||
ignore_default_args=["--enable-automation"],
|
||||
)
|
||||
else:
|
||||
browser_holder = pw.chromium.launch(
|
||||
headless=hl,
|
||||
executable_path=chrome,
|
||||
args=args,
|
||||
ignore_default_args=["--enable-automation"],
|
||||
)
|
||||
context = browser_holder.new_context(no_viewport=True, locale="zh-CN")
|
||||
|
||||
context.add_init_script(STEALTH_INIT_SCRIPT)
|
||||
page = context.new_page()
|
||||
page.set_default_timeout(DEFAULT_PAGE_TIMEOUT_MS)
|
||||
page.goto(start_url, wait_until="domcontentloaded", timeout=GOTO_TIMEOUT_MS)
|
||||
yield page
|
||||
finally:
|
||||
try:
|
||||
if context is not None:
|
||||
context.close()
|
||||
except Exception as exc:
|
||||
logger.warning("browser_context_close_failed: %s", exc)
|
||||
if browser_holder is not None:
|
||||
try:
|
||||
browser_holder.close()
|
||||
except Exception as exc:
|
||||
logger.warning("browser_close_failed: %s", exc)
|
||||
|
||||
|
||||
def find_chrome_executable() -> str | None:
|
||||
return _find_chrome_executable()
|
||||
104
examples/simulator_browser_rpa/scripts/service/task_service.py
Normal file
104
examples/simulator_browser_rpa/scripts/service/task_service.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""轻量任务编排:校验输入 → 选 adapter → 提交批次。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from service.account_client import pick_simulator_account
|
||||
from service.adapter import BatchItem, SimulatorBrowserRpaAdapter, select_adapter
|
||||
from util.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def _validate_items(items: List[Dict[str, Any]]) -> tuple[List[BatchItem], Optional[str]]:
|
||||
if not items:
|
||||
return [], "items 不能为空"
|
||||
parsed: List[BatchItem] = []
|
||||
for idx, raw in enumerate(items, start=1):
|
||||
name = str(raw.get("name") or "").strip()
|
||||
account = str(raw.get("account") or "").strip()
|
||||
note = str(raw.get("note") or "").strip()
|
||||
try:
|
||||
amount = float(raw.get("amount"))
|
||||
except (TypeError, ValueError):
|
||||
return [], f"第 {idx} 行 amount 无效"
|
||||
if amount <= 0:
|
||||
return [], f"第 {idx} 行 amount 必须大于 0"
|
||||
if not name or not account:
|
||||
return [], f"第 {idx} 行 name/account 不能为空"
|
||||
parsed.append(
|
||||
BatchItem(
|
||||
row_index=int(raw.get("row_index") or idx),
|
||||
name=name,
|
||||
account=account,
|
||||
amount=amount,
|
||||
note=note,
|
||||
)
|
||||
)
|
||||
return parsed, None
|
||||
|
||||
|
||||
def run_batch_submit(
|
||||
target: str,
|
||||
items: List[Dict[str, Any]],
|
||||
*,
|
||||
force: bool = False,
|
||||
artifacts_dir: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
最小编排示例。
|
||||
|
||||
真实 skill 中还应:写 task_logs、entitlement、RpaVideoSession 等。
|
||||
"""
|
||||
del force # 示例占位;真实 skill 可用于跳过确认
|
||||
|
||||
target_key = (target or "").strip()
|
||||
if not target_key:
|
||||
return {"success": False, "error": {"code": "INVALID_TARGET", "message": "target 不能为空"}}
|
||||
|
||||
batch_items, err = _validate_items(items)
|
||||
if err:
|
||||
return {"success": False, "error": {"code": "INVALID_ITEMS", "message": err}}
|
||||
|
||||
art_dir = artifacts_dir or os.path.join(
|
||||
tempfile.gettempdir(),
|
||||
"openclaw-simulator-rpa-artifacts",
|
||||
uuid.uuid4().hex[:8],
|
||||
)
|
||||
os.makedirs(art_dir, exist_ok=True)
|
||||
logger.info("batch_submit_start target=%s item_count=%s artifacts_dir=%s", target_key, len(batch_items), art_dir)
|
||||
|
||||
adapter = select_adapter(artifacts_dir=art_dir)
|
||||
if isinstance(adapter, SimulatorBrowserRpaAdapter):
|
||||
account = pick_simulator_account()
|
||||
adapter.set_credentials(
|
||||
account.login_id,
|
||||
account.password,
|
||||
account.token_pin,
|
||||
account.profile_dir,
|
||||
)
|
||||
|
||||
result = adapter.submit_batch(target_key, batch_items)
|
||||
summary: Dict[str, Any] = {
|
||||
"success": result.ok,
|
||||
"target": target_key,
|
||||
"adapter": result.artifacts.get("adapter") or adapter.name,
|
||||
"submitted_count": result.submitted_count,
|
||||
"submitted_amount": result.submitted_amount,
|
||||
"batch_id": result.batch_id,
|
||||
"artifacts": result.artifacts,
|
||||
"artifacts_dir": art_dir,
|
||||
}
|
||||
if not result.ok:
|
||||
summary["error"] = {"code": "BATCH_SUBMIT_FAILED", "message": result.error_msg or "提交失败"}
|
||||
logger.info(
|
||||
"batch_submit_done success=%s batch_id=%s adapter=%s",
|
||||
result.ok,
|
||||
result.batch_id,
|
||||
adapter.name,
|
||||
)
|
||||
return summary
|
||||
34
examples/simulator_browser_rpa/scripts/util/constants.py
Normal file
34
examples/simulator_browser_rpa/scripts/util/constants.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""仿真浏览器 RPA 示例常量。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
SKILL_SLUG = "example-simulator-browser-rpa"
|
||||
LOG_LOGGER_NAME = "openclaw.skill.example_simulator_browser_rpa"
|
||||
|
||||
DEFAULT_TIMEOUT_MS = 15_000
|
||||
LOGIN_NAVIGATE_TIMEOUT_MS = 30_000
|
||||
SUBMIT_NAVIGATE_TIMEOUT_MS = 60_000
|
||||
|
||||
DEFAULT_DEMO_LOGIN_ID = "demo001"
|
||||
DEFAULT_DEMO_PASSWORD = "demo-password"
|
||||
DEFAULT_DEMO_CAPTCHA = "0000"
|
||||
DEFAULT_DEMO_TOKEN_PIN = "123456"
|
||||
|
||||
ENV_SIMULATOR_BASE_URL = "SIMULATOR_BASE_URL"
|
||||
ENV_SIMULATOR_LOGIN_ID = "SIMULATOR_LOGIN_ID"
|
||||
ENV_SIMULATOR_PASSWORD = "SIMULATOR_PASSWORD"
|
||||
ENV_SIMULATOR_TOKEN_PIN = "SIMULATOR_TOKEN_PIN"
|
||||
ENV_SIMULATOR_PROFILE_DIR = "SIMULATOR_PROFILE_DIR"
|
||||
|
||||
_EXAMPLE_ROOT = Path(__file__).resolve().parents[2]
|
||||
DEFAULT_DEMO_APP_PATH = _EXAMPLE_ROOT / "sandbox" / "demo_app.html"
|
||||
|
||||
|
||||
def resolve_simulator_base_url() -> str:
|
||||
env = (os.getenv(ENV_SIMULATOR_BASE_URL) or "").strip()
|
||||
if env:
|
||||
return env.rstrip("/")
|
||||
return DEFAULT_DEMO_APP_PATH.as_uri()
|
||||
15
examples/simulator_browser_rpa/scripts/util/logging.py
Normal file
15
examples/simulator_browser_rpa/scripts/util/logging.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""示例日志工具。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from util.constants import LOG_LOGGER_NAME
|
||||
|
||||
|
||||
def get_logger(name: str | None = None) -> logging.Logger:
|
||||
return logging.getLogger(name or LOG_LOGGER_NAME)
|
||||
|
||||
|
||||
def configure_logging(level: int = logging.INFO) -> None:
|
||||
logging.basicConfig(level=level, format="%(levelname)s %(name)s %(message)s")
|
||||
0
examples/simulator_browser_rpa/tests/.gitkeep
Normal file
0
examples/simulator_browser_rpa/tests/.gitkeep
Normal file
10
examples/simulator_browser_rpa/tests/conftest.py
Normal file
10
examples/simulator_browser_rpa/tests/conftest.py
Normal file
@@ -0,0 +1,10 @@
|
||||
"""pytest 路径:将 examples/simulator_browser_rpa/scripts 加入 import 路径。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPTS = Path(__file__).resolve().parents[1] / "scripts"
|
||||
if str(SCRIPTS) not in sys.path:
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
@@ -0,0 +1,64 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""adapter 工厂 dispatch 单元测试。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from service.adapter import (
|
||||
BatchItem,
|
||||
MockBatchAdapter,
|
||||
SimulatorBrowserRpaAdapter,
|
||||
select_adapter,
|
||||
)
|
||||
|
||||
|
||||
class TestAdapterDispatch(unittest.TestCase):
|
||||
def test_default_returns_simulator_rpa(self) -> None:
|
||||
with patch.dict(os.environ, {}, clear=False):
|
||||
os.environ.pop("OPENCLAW_TEST_TARGET", None)
|
||||
adapter = select_adapter()
|
||||
self.assertIsInstance(adapter, SimulatorBrowserRpaAdapter)
|
||||
|
||||
def test_unit_returns_mock(self) -> None:
|
||||
with patch.dict(os.environ, {"OPENCLAW_TEST_TARGET": "unit"}):
|
||||
self.assertIsInstance(select_adapter(), MockBatchAdapter)
|
||||
|
||||
def test_mock_returns_mock(self) -> None:
|
||||
with patch.dict(os.environ, {"OPENCLAW_TEST_TARGET": "mock"}):
|
||||
self.assertIsInstance(select_adapter(), MockBatchAdapter)
|
||||
|
||||
def test_simulator_rpa_returns_simulator(self) -> None:
|
||||
with patch.dict(os.environ, {"OPENCLAW_TEST_TARGET": "simulator_rpa"}):
|
||||
self.assertIsInstance(select_adapter(), SimulatorBrowserRpaAdapter)
|
||||
|
||||
def test_real_rpa_falls_back_to_mock(self) -> None:
|
||||
with patch.dict(os.environ, {"OPENCLAW_TEST_TARGET": "real_rpa"}):
|
||||
self.assertIsInstance(select_adapter(), MockBatchAdapter)
|
||||
|
||||
def test_unknown_target_falls_back_simulator_rpa(self) -> None:
|
||||
with patch.dict(os.environ, {"OPENCLAW_TEST_TARGET": "simulator_api"}):
|
||||
self.assertIsInstance(select_adapter(), SimulatorBrowserRpaAdapter)
|
||||
|
||||
|
||||
class TestMockAdapter(unittest.TestCase):
|
||||
def test_mock_success(self) -> None:
|
||||
items = [
|
||||
BatchItem(row_index=1, name="Alice", account="1001", amount=10.5),
|
||||
BatchItem(row_index=2, name="Bob", account="1002", amount=20.0),
|
||||
]
|
||||
result = MockBatchAdapter().submit_batch("acct-demo-001", items)
|
||||
self.assertTrue(result.ok)
|
||||
self.assertEqual(result.submitted_count, 2)
|
||||
self.assertEqual(result.submitted_amount, 30.5)
|
||||
self.assertTrue((result.batch_id or "").startswith("MOCK-"))
|
||||
|
||||
def test_mock_empty_items(self) -> None:
|
||||
result = MockBatchAdapter().submit_batch("acct-demo-001", [])
|
||||
self.assertFalse(result.ok)
|
||||
self.assertEqual(result.error_msg, "items 为空")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,76 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""SimulatorBrowserRpaAdapter 单元测试(不启动真实浏览器)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from service.adapter import BatchItem, SimulatorBrowserRpaAdapter
|
||||
from service.browser_session import CHROME_LAUNCH_ARGS
|
||||
|
||||
|
||||
class TestSimulatorRpaUnit(unittest.TestCase):
|
||||
def test_set_credentials_and_empty_profile_dir(self) -> None:
|
||||
adapter = SimulatorBrowserRpaAdapter()
|
||||
adapter.set_credentials("demo001", "pwd", "pin", " ")
|
||||
self.assertEqual(adapter._login_id, "demo001")
|
||||
self.assertIsNone(adapter._profile_dir)
|
||||
|
||||
def test_submit_batch_empty_items(self) -> None:
|
||||
adapter = SimulatorBrowserRpaAdapter()
|
||||
result = adapter.submit_batch("acct-demo-001", [])
|
||||
self.assertFalse(result.ok)
|
||||
self.assertIn("items", result.error_msg or "")
|
||||
|
||||
@patch("service.adapter.simulator_rpa.find_chrome_executable", return_value=None)
|
||||
def test_no_chrome_returns_error_without_launch(self, _chrome: MagicMock) -> None:
|
||||
adapter = SimulatorBrowserRpaAdapter()
|
||||
items = [BatchItem(row_index=1, name="A", account="1", amount=1.0)]
|
||||
result = adapter.submit_batch("acct-demo-001", items)
|
||||
self.assertFalse(result.ok)
|
||||
msg = result.error_msg or ""
|
||||
self.assertTrue("Chrome" in msg or "Edge" in msg or "浏览器" in msg)
|
||||
|
||||
def test_chrome_launch_args_must_not_contain_url(self) -> None:
|
||||
for arg in CHROME_LAUNCH_ARGS:
|
||||
lowered = arg.lower()
|
||||
self.assertFalse(lowered.startswith("http://"))
|
||||
self.assertFalse(lowered.startswith("https://"))
|
||||
self.assertFalse(lowered.startswith("file://"))
|
||||
|
||||
@patch("service.adapter.simulator_rpa.browser_session")
|
||||
@patch("service.adapter.simulator_rpa.find_chrome_executable", return_value="C:/Chrome/chrome.exe")
|
||||
def test_start_url_opened_via_goto_not_launch_args(
|
||||
self,
|
||||
_chrome: MagicMock,
|
||||
mock_session: MagicMock,
|
||||
) -> None:
|
||||
from service.adapter.simulator_rpa import RpaError
|
||||
|
||||
adapter = SimulatorBrowserRpaAdapter(base_url="file:///demo/demo_app.html")
|
||||
items = [BatchItem(row_index=1, name="A", account="1", amount=1.0)]
|
||||
|
||||
page = MagicMock()
|
||||
mock_cm = MagicMock()
|
||||
mock_cm.__enter__.return_value = page
|
||||
mock_cm.__exit__.return_value = False
|
||||
mock_session.return_value = mock_cm
|
||||
|
||||
with patch.object(adapter, "_login", side_effect=RpaError("login fail")):
|
||||
result = adapter.submit_batch("acct-demo-001", items)
|
||||
|
||||
self.assertFalse(result.ok)
|
||||
mock_session.assert_called_once()
|
||||
called_start_url = mock_session.call_args[0][0]
|
||||
self.assertEqual(called_start_url, "file:///demo/demo_app.html")
|
||||
for arg in CHROME_LAUNCH_ARGS:
|
||||
self.assertNotIn(arg, called_start_url)
|
||||
|
||||
def test_safe_screenshot_without_artifacts_dir(self) -> None:
|
||||
adapter = SimulatorBrowserRpaAdapter(artifacts_dir=None)
|
||||
page = MagicMock()
|
||||
self.assertIsNone(adapter._safe_screenshot(page, "tag"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
55
examples/simulator_browser_rpa/tests/test_stop_reason.py
Normal file
55
examples/simulator_browser_rpa/tests/test_stop_reason.py
Normal file
@@ -0,0 +1,55 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""RPA 失败结果与 artifacts 单元测试。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from service.adapter import BatchItem, SimulatorBrowserRpaAdapter
|
||||
from service.adapter.simulator_rpa import RpaError
|
||||
|
||||
|
||||
class TestBatchSubmitFailureResult(unittest.TestCase):
|
||||
@patch("service.adapter.simulator_rpa.find_chrome_executable", return_value="C:/Chrome/chrome.exe")
|
||||
@patch("service.adapter.simulator_rpa.browser_session")
|
||||
def test_rpa_error_converts_to_failed_result(self, mock_session: MagicMock, _chrome: MagicMock) -> None:
|
||||
page = MagicMock()
|
||||
mock_cm = MagicMock()
|
||||
mock_cm.__enter__.return_value = page
|
||||
mock_cm.__exit__.return_value = False
|
||||
mock_session.return_value = mock_cm
|
||||
|
||||
adapter = SimulatorBrowserRpaAdapter(artifacts_dir="/tmp/art")
|
||||
items = [BatchItem(row_index=1, name="A", account="1", amount=1.0)]
|
||||
|
||||
with patch.object(adapter, "_login", side_effect=RpaError("登录第一步失败:timeout", screenshot_path="/tmp/art/x.png")):
|
||||
result = adapter.submit_batch("acct-demo-001", items)
|
||||
|
||||
self.assertFalse(result.ok)
|
||||
self.assertIn("登录第一步失败", result.error_msg or "")
|
||||
self.assertEqual(result.artifacts.get("adapter"), "simulator_browser_rpa")
|
||||
self.assertEqual(result.artifacts.get("screenshot"), "/tmp/art/x.png")
|
||||
|
||||
@patch("service.adapter.simulator_rpa.find_chrome_executable", return_value="C:/Chrome/chrome.exe")
|
||||
@patch("service.adapter.simulator_rpa.browser_session")
|
||||
def test_unexpected_error_includes_adapter_name(self, mock_session: MagicMock, _chrome: MagicMock) -> None:
|
||||
page = MagicMock()
|
||||
mock_cm = MagicMock()
|
||||
mock_cm.__enter__.return_value = page
|
||||
mock_cm.__exit__.return_value = False
|
||||
mock_session.return_value = mock_cm
|
||||
|
||||
adapter = SimulatorBrowserRpaAdapter(artifacts_dir=None)
|
||||
items = [BatchItem(row_index=1, name="A", account="1", amount=1.0)]
|
||||
|
||||
with patch.object(adapter, "_login", side_effect=RuntimeError("boom")):
|
||||
result = adapter.submit_batch("acct-demo-001", items)
|
||||
|
||||
self.assertFalse(result.ok)
|
||||
self.assertIn("未预期异常", result.error_msg or "")
|
||||
self.assertEqual(result.artifacts.get("adapter"), "simulator_browser_rpa")
|
||||
self.assertNotIn("screenshot", result.artifacts)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user