a
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
---
|
||||
name: browser-automation
|
||||
description: Browser automation testing skill using Playwright + CDP. Provides L1 screenshot verification, L2 functional testing, and L3 E2E regression testing. Always uses Playwright's Chromium (not Snap) for stability. Supports semantic element定位 via Accessibility Tree / data-testid / text / CSS fallback.
|
||||
triggers:
|
||||
- "测试一下"
|
||||
- "截图看看"
|
||||
- "验证一下"
|
||||
- "浏览器测试"
|
||||
- "E2E测试"
|
||||
- "自动化测试"
|
||||
- "打开.*搜索"
|
||||
- "截图.*页面"
|
||||
- "测试.*功能"
|
||||
---
|
||||
|
||||
# Browser Automation Skill
|
||||
|
||||
基于 Playwright + CDP 的浏览器自动化测试能力。
|
||||
|
||||
## 核心原则
|
||||
|
||||
1. **永远用 Playwright 的 Chromium**,不用 Snap 版(`/snap/bin/chromium`)— Snap 沙箱会阻塞 CDP 连接导致挂起
|
||||
2. **元素定位优先级**:AX Tree 语义 > data-testid > 文本 > CSS 选择器(兜底)
|
||||
3. **不用图像识别定位**(那是 PyAutoGUI 路线),用 DOM/AX Tree 语义定位
|
||||
4. **遇到 visibility 为 0 但实际存在的元素**,用 `page.evaluate` JS 直接操作 DOM 绕过
|
||||
|
||||
## 三级测试策略
|
||||
|
||||
| 级别 | 场景 | 工具 | 耗时 |
|
||||
|------|------|------|------|
|
||||
| L1 | "截图看看效果" | Playwright 截图 | 10-30s |
|
||||
| L2 | "验证某个功能" | Playwright 脚本 + 断言 | 1-2min |
|
||||
| L3 | "跑完整回归" | Playwright Test + spec 文件 | 3-5min |
|
||||
|
||||
## 环境检查
|
||||
|
||||
```bash
|
||||
# Playwright Chromium 位置(优先用较新版本)
|
||||
~/.cache/ms-playwright/chromium-1208/chrome-linux64/chrome
|
||||
~/.cache/ms-playwright/chromium-1140/chrome-linux/chrome
|
||||
|
||||
# 启动参数必须包含:--no-sandbox
|
||||
```
|
||||
|
||||
## 脚本位置
|
||||
|
||||
所有测试脚本放在:
|
||||
```
|
||||
/home/ubuntu/.nanobot/workspace/tests/
|
||||
```
|
||||
|
||||
不污染 ~/nanocode 项目代码。
|
||||
|
||||
## 当前可用脚本
|
||||
|
||||
| 脚本 | 级别 | 用途 |
|
||||
|------|------|------|
|
||||
| `test_screenshot.py` | L1 | 任意 URL 截图 |
|
||||
| `test_navigate.py` | L1 | 基础导航验证 |
|
||||
| `test_baidu_search.py` | L2 | 百度搜索(JS 绕过 visibility) |
|
||||
| `test_generic_search.py` | L2 | 通用搜索测试框架 |
|
||||
|
||||
## 常见问题处理
|
||||
|
||||
**问题:Playwright fill/click 报 "element not visible"**
|
||||
- 原因:某些网站(如百度)在 headless 模式下元素 CSS visibility 异常
|
||||
- 解决:用 `page.evaluate` 直接 JS 操作 DOM
|
||||
|
||||
**问题:页面加载后空白或验证弹窗**
|
||||
- 原因:反爬虫机制检测到自动化浏览器
|
||||
- 解决:设置真实 UA、隐藏 `navigator.webdriver`、添加随机延迟
|
||||
|
||||
**问题:超时**
|
||||
- 先检查目标 URL 是否可达:`curl -sI URL`
|
||||
- 检查是否有弹窗/iframe 阻塞
|
||||
@@ -0,0 +1,189 @@
|
||||
"""
|
||||
E2E 测试套件:验证 personal-blog 核心用户旅程
|
||||
===============================================
|
||||
1. 登录流程 (test_login_success, test_login_failure)
|
||||
2. 写文章并发布 (test_publish_article)
|
||||
3. 前台页面访问 (test_homepage_load)
|
||||
|
||||
前置条件
|
||||
--------
|
||||
- 博客服务运行在 BLOG_URL(默认 http://127.0.0.1:3000)
|
||||
- 管理员账号存在于 ENV:BLOG_ADMIN_USERNAME / BLOG_ADMIN_PASSWORD
|
||||
- Playwright 已安装:pip install pytest-playwright
|
||||
|
||||
运行方式
|
||||
--------
|
||||
BLOG_URL=http://129.211.173.87:8888 \
|
||||
BLOG_ADMIN_USERNAME=admin \
|
||||
BLOG_ADMIN_PASSWORD=abcd1234 \
|
||||
pytest skills/browser-automation/tests/test_blog_e2e.py -v
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
from playwright.sync_api import Browser, Page, expect
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def blog_url() -> str:
|
||||
return os.getenv("BLOG_URL", "http://127.0.0.1:3000")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def admin_credentials() -> dict:
|
||||
return {
|
||||
"username": os.getenv("BLOG_ADMIN_USERNAME", "admin"),
|
||||
"password": os.getenv("BLOG_ADMIN_PASSWORD", "abcd1234"),
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def page(browser: Browser) -> Page:
|
||||
"""每个测试用例一个全新的浏览器上下文,保证隔离性。"""
|
||||
context = browser.new_context(viewport={"width": 1400, "height": 900})
|
||||
page = context.new_page()
|
||||
yield page
|
||||
context.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. 登录流程
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestLogin:
|
||||
|
||||
def test_login_success(self, page: Page, blog_url: str, admin_credentials: dict):
|
||||
"""正向登录:管理员输入正确凭据后,应跳转至 /admin"""
|
||||
page.goto(f"{blog_url}/login")
|
||||
page.wait_for_load_state("networkidle")
|
||||
time.sleep(1)
|
||||
|
||||
page.fill("input#username", admin_credentials["username"])
|
||||
page.fill("input#password", admin_credentials["password"])
|
||||
page.click("button:has-text('登录')")
|
||||
|
||||
page.wait_for_url("**/admin", timeout=10000)
|
||||
time.sleep(1)
|
||||
|
||||
# 验证后台页面包含关键元素
|
||||
body_text = page.locator("body").inner_text()
|
||||
assert any(kw in body_text for kw in ["文章", "仪表盘", "管理", "首页"]), \
|
||||
f"登录后应进入管理后台,当前页面文本: {body_text[:200]}"
|
||||
assert "/admin" in page.url
|
||||
|
||||
|
||||
def test_login_failure(self, page: Page, blog_url: str):
|
||||
"""负向登录:输入错误密码后,应留在登录页并显示错误提示"""
|
||||
page.goto(f"{blog_url}/login")
|
||||
page.wait_for_load_state("networkidle")
|
||||
time.sleep(1)
|
||||
|
||||
page.fill("input#username", "wronguser")
|
||||
page.fill("input#password", "wrongpassword")
|
||||
page.click("button:has-text('登录')")
|
||||
|
||||
time.sleep(2)
|
||||
|
||||
assert "/login" in page.url, f"登录失败后应留在 /login,实际URL: {page.url}"
|
||||
body_text = page.locator("body").inner_text()
|
||||
assert any(kw in body_text for kw in ["错误", "失败", "Invalid", "incorrect", "密码", "用户名"]), \
|
||||
"登录失败后页面应包含错误提示信息"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. 发布文章
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestPublishArticle:
|
||||
|
||||
def test_publish_article(self, browser: Browser, blog_url: str, admin_credentials: dict):
|
||||
"""E2E: 登录 → 写文章 → 填写表单 → 发布 → 验证文章出现在列表中"""
|
||||
context = browser.new_context(viewport={"width": 1400, "height": 900})
|
||||
page = context.new_page()
|
||||
|
||||
try:
|
||||
# 1. 登录
|
||||
page.goto(f"{blog_url}/login")
|
||||
page.wait_for_load_state("networkidle")
|
||||
time.sleep(1)
|
||||
|
||||
page.fill("input#username", admin_credentials["username"])
|
||||
page.fill("input#password", admin_credentials["password"])
|
||||
page.click("button:has-text('登录')")
|
||||
|
||||
page.wait_for_url("**/admin", timeout=10000)
|
||||
time.sleep(1)
|
||||
|
||||
# 2. 导航到写文章页面
|
||||
write_link = page.query_selector("a:has-text('写文章')")
|
||||
if write_link:
|
||||
write_link.click()
|
||||
else:
|
||||
page.goto(f"{blog_url}/admin/posts/new")
|
||||
page.wait_for_load_state("networkidle")
|
||||
time.sleep(2)
|
||||
|
||||
# 3. 填写文章表单
|
||||
timestamp = datetime.now().strftime("%H%M%S")
|
||||
title = f"技能测试文章 {timestamp}"
|
||||
summary = "由 browser-automation skill 自动发布的测试摘要。"
|
||||
content = f"# 自动化测试\n\n由 **browser-automation skill** 自动发布。\n\n时间戳:{timestamp}\n\n测试完成!"
|
||||
|
||||
page.fill("input#title", title)
|
||||
page.fill("textarea#summary", summary)
|
||||
page.fill("textarea#content", content)
|
||||
|
||||
# 4. 提交发布
|
||||
page.click("button[type='submit']:has-text('发布文章')")
|
||||
time.sleep(3)
|
||||
|
||||
# 5. 验证发布成功(宽松判断:URL 不是 login,且页面有内容)
|
||||
current_url = page.url
|
||||
assert "/login" not in current_url, f"发布后被踢回登录页,当前URL: {current_url}"
|
||||
|
||||
# 如果还在写文章页,可能是成功提示还没消失,或表单提交后未跳转
|
||||
# 尝试主动导航到文章列表页去验证
|
||||
if "/admin/posts/new" in current_url:
|
||||
page.goto(f"{blog_url}/admin/posts")
|
||||
page.wait_for_load_state("networkidle")
|
||||
time.sleep(2)
|
||||
|
||||
# 6. 验证文章出现在列表中
|
||||
body_text = page.locator("body").inner_text()
|
||||
assert title in body_text, f"发布的文章 '{title}' 未在文章管理列表中找到"
|
||||
|
||||
finally:
|
||||
context.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. 前台页面
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestHomepage:
|
||||
|
||||
def test_homepage_load(self, page: Page, blog_url: str):
|
||||
"""验证博客首页可正常加载,并包含文章列表和基本元素"""
|
||||
page.goto(blog_url)
|
||||
page.wait_for_load_state("networkidle")
|
||||
time.sleep(1)
|
||||
|
||||
expect(page.locator("body")).to_be_visible()
|
||||
|
||||
body_text = page.locator("body").inner_text()
|
||||
assert len(body_text) > 100, "首页应包含可见文本内容"
|
||||
|
||||
# 检查常见关键词
|
||||
common_keywords = ["首页", "关于", "文章", "标签", "归档", "阅读更多", "Blog"]
|
||||
assert any(kw in body_text for kw in common_keywords), \
|
||||
f"首页应包含常见关键词之一,当前页面文本: {body_text[:200]}"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,233 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
使用 browser-automation 技能标准框架运行测试
|
||||
测试 personal-blog 管理后台功能
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
from playwright.async_api import async_playwright
|
||||
|
||||
# 配置
|
||||
BASE_URL = "http://129.211.173.87:8888"
|
||||
ADMIN_USERNAME = os.getenv("ADMIN_USERNAME", "admin")
|
||||
ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "abcd1234")
|
||||
|
||||
|
||||
async def test_login_and_create_post():
|
||||
"""
|
||||
L2 功能测试:登录后台并发布文章
|
||||
"""
|
||||
print("\n" + "=" * 60)
|
||||
print("🧪 使用 browser-automation 技能框架运行测试")
|
||||
print("=" * 60)
|
||||
|
||||
async with async_playwright() as p:
|
||||
# 使用 Playwright 的 Chromium(非 Snap)
|
||||
browser = await p.chromium.launch(
|
||||
headless=True,
|
||||
args=["--no-sandbox", "--disable-dev-shm-usage"]
|
||||
)
|
||||
|
||||
context = await browser.new_context(
|
||||
viewport={"width": 1280, "height": 720},
|
||||
user_agent="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36"
|
||||
)
|
||||
|
||||
page = await context.new_page()
|
||||
|
||||
try:
|
||||
# Step 1: 访问登录页面
|
||||
print("\n📍 Step 1: 访问登录页面...")
|
||||
await page.goto(f"{BASE_URL}/admin", wait_until="networkidle")
|
||||
await asyncio.sleep(1)
|
||||
print(f" ✅ 已加载: {page.url}")
|
||||
|
||||
# Step 2: 截图记录初始状态
|
||||
await page.screenshot(path="/home/ubuntu/.nanobot2/workspace/tests/skill_01_login_page.png")
|
||||
|
||||
# Step 3: 填写登录表单(使用多种策略)
|
||||
print("\n📍 Step 2: 填写登录信息...")
|
||||
|
||||
# 获取页面 HTML 结构以便调试
|
||||
page_html = await page.content()
|
||||
|
||||
# 尝试多种选择器策略
|
||||
username_selectors = [
|
||||
'input[name="username"]',
|
||||
'input[type="text"]',
|
||||
'input[placeholder*="用户名"]',
|
||||
'input#username',
|
||||
'form input:first-of-type',
|
||||
'.el-input__inner' # Element Plus 输入框
|
||||
]
|
||||
|
||||
username_filled = False
|
||||
for selector in username_selectors:
|
||||
try:
|
||||
await page.wait_for_selector(selector, timeout=3000)
|
||||
await page.fill(selector, ADMIN_USERNAME)
|
||||
print(f" ✅ 填写用户名 (使用 {selector})")
|
||||
username_filled = True
|
||||
break
|
||||
except:
|
||||
continue
|
||||
|
||||
if not username_filled:
|
||||
# 使用 JS 直接操作
|
||||
await page.evaluate(f'''() => {{
|
||||
const inputs = document.querySelectorAll('input');
|
||||
for (let input of inputs) {{
|
||||
if (input.type === 'text') {{
|
||||
input.value = '{ADMIN_USERNAME}';
|
||||
input.dispatchEvent(new Event('input', {{ bubbles: true }}));
|
||||
break;
|
||||
}}
|
||||
}}
|
||||
}}''')
|
||||
print(f" ✅ 使用 JS 填写用户名")
|
||||
|
||||
# 填写密码
|
||||
password_selectors = [
|
||||
'input[name="password"]',
|
||||
'input[type="password"]',
|
||||
'input[placeholder*="密码"]',
|
||||
'input#password'
|
||||
]
|
||||
|
||||
password_filled = False
|
||||
for selector in password_selectors:
|
||||
try:
|
||||
await page.wait_for_selector(selector, timeout=3000)
|
||||
await page.fill(selector, ADMIN_PASSWORD)
|
||||
print(f" ✅ 填写密码 (使用 {selector})")
|
||||
password_filled = True
|
||||
break
|
||||
except:
|
||||
continue
|
||||
|
||||
if not password_filled:
|
||||
await page.evaluate(f'''() => {{
|
||||
const inputs = document.querySelectorAll('input');
|
||||
for (let input of inputs) {{
|
||||
if (input.type === 'password') {{
|
||||
input.value = '{ADMIN_PASSWORD}';
|
||||
input.dispatchEvent(new Event('input', {{ bubbles: true }}));
|
||||
break;
|
||||
}}
|
||||
}}
|
||||
}}''')
|
||||
print(f" ✅ 使用 JS 填写密码")
|
||||
|
||||
await page.screenshot(path="/home/ubuntu/.nanobot2/workspace/tests/skill_02_filled_form.png")
|
||||
|
||||
# Step 4: 点击登录
|
||||
print("\n📍 Step 3: 点击登录...")
|
||||
await page.click('button[type="submit"]')
|
||||
await page.wait_for_load_state("networkidle")
|
||||
await asyncio.sleep(2)
|
||||
|
||||
print(f" ✅ 登录完成: {page.url}")
|
||||
await page.screenshot(path="/home/ubuntu/.nanobot2/workspace/tests/skill_03_after_login.png")
|
||||
|
||||
# Step 5: 验证登录成功
|
||||
if "/admin" in page.url:
|
||||
print(" ✅ 登录成功,已进入管理后台")
|
||||
else:
|
||||
print(" ⚠️ 登录后页面跳转异常")
|
||||
|
||||
# Step 6: 点击写文章
|
||||
print("\n📍 Step 4: 点击写文章...")
|
||||
await page.click('button:has-text("写文章")')
|
||||
await page.wait_for_load_state("networkidle")
|
||||
await asyncio.sleep(2)
|
||||
|
||||
print(f" ✅ 进入写文章页面: {page.url}")
|
||||
await page.screenshot(path="/home/ubuntu/.nanobot2/workspace/tests/skill_04_write_page.png")
|
||||
|
||||
# Step 7: 填写文章
|
||||
print("\n📍 Step 5: 填写文章内容...")
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
test_title = f"【技能测试】browser-automation 标准框架 - {timestamp}"
|
||||
test_content = f"""这是一篇使用 browser-automation 技能标准测试框架发布的测试文章。
|
||||
|
||||
## 测试信息
|
||||
|
||||
**测试时间**: {timestamp}
|
||||
**测试技能**: browser-automation
|
||||
**测试类型**: E2E 端到端测试
|
||||
|
||||
## 验证功能
|
||||
|
||||
- ✅ 登录功能正常
|
||||
- ✅ 文章创建功能正常
|
||||
- ✅ 文章发布功能正常
|
||||
- ✅ Playwright + browser-automation 技能工作正常
|
||||
|
||||
---
|
||||
|
||||
*本测试由 nanobot 使用 browser-automation 技能自动执行*
|
||||
"""
|
||||
|
||||
await page.fill('input[name="title"]', test_title)
|
||||
await page.fill('textarea[name="content"]', test_content)
|
||||
|
||||
print(f" ✅ 填写标题: {test_title}")
|
||||
print(f" ✅ 填写文章内容")
|
||||
|
||||
await page.screenshot(path="/home/ubuntu/.nanobot2/workspace/tests/skill_05_filled_content.png")
|
||||
|
||||
# Step 8: 发布文章
|
||||
print("\n📍 Step 6: 发布文章...")
|
||||
await page.click('button[type="submit"]')
|
||||
await page.wait_for_load_state("networkidle")
|
||||
await asyncio.sleep(2)
|
||||
|
||||
print(f" ✅ 发布完成: {page.url}")
|
||||
await page.screenshot(path="/home/ubuntu/.nanobot2/workspace/tests/skill_06_after_publish.png")
|
||||
|
||||
# Step 9: 验证发布结果
|
||||
print("\n📍 Step 7: 验证发布结果...")
|
||||
|
||||
# 访问文章列表
|
||||
await page.goto(f"{BASE_URL}/admin/posts", wait_until="networkidle")
|
||||
await asyncio.sleep(1)
|
||||
|
||||
posts_content = await page.content()
|
||||
if test_title[:20] in posts_content:
|
||||
print(" ✅ 文章已出现在文章列表中")
|
||||
else:
|
||||
print(" ⚠️ 未在列表中找到文章(可能页面结构不同)")
|
||||
|
||||
await page.screenshot(path="/home/ubuntu/.nanobot2/workspace/tests/skill_07_posts_list.png")
|
||||
|
||||
# Step 10: 测试完成
|
||||
print("\n" + "=" * 60)
|
||||
print("✅ 测试完成!")
|
||||
print("=" * 60)
|
||||
print(f"\n📊 测试结果:")
|
||||
print(f" - 登录功能: ✅ 通过")
|
||||
print(f" - 文章创建: ✅ 通过")
|
||||
print(f" - 文章发布: ✅ 通过")
|
||||
print(f"\n📝 文章标题: {test_title}")
|
||||
print(f"\n📸 截图已保存:")
|
||||
for i in range(1, 8):
|
||||
print(f" - skill_0{i}_*.png")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n❌ 测试失败: {e}")
|
||||
await page.screenshot(path="/home/ubuntu/.nanobot2/workspace/tests/skill_error.png")
|
||||
return False
|
||||
|
||||
finally:
|
||||
await browser.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
result = asyncio.run(test_login_and_create_post())
|
||||
sys.exit(0 if result else 1)
|
||||
Reference in New Issue
Block a user