新增UI测试步骤支持,优化execute_ui_test_case函数,更新相关数据结构和数据库表

This commit is contained in:
longpeng 2025-06-30 00:04:49 +08:00
parent b7263f1814
commit de3972577c
16 changed files with 149 additions and 45 deletions

2
.gitignore vendored
View File

@ -183,5 +183,5 @@ cython_debug/
*pb2.py* *pb2.py*
*pb.go *pb.go
*.sql #*.sql
*.yaml *.yaml

View File

@ -44,12 +44,6 @@ CREATE TABLE `composite_case_steps`
`parameters_json` JSON NULL COMMENT '步骤特定参数,例如:{"endpoint": "/users", "method": "GET"}', `parameters_json` JSON NULL COMMENT '步骤特定参数,例如:{"endpoint": "/users", "method": "GET"}',
`is_required` BOOLEAN DEFAULT TRUE COMMENT '是否必需步骤', `is_required` BOOLEAN DEFAULT TRUE COMMENT '是否必需步骤',
`step_description` TEXT NULL COMMENT '步骤描述', `step_description` TEXT NULL COMMENT '步骤描述',
`selector` VARCHAR(255) NULL COMMENT 'UI元素选择器',
`input_value` VARCHAR(255) NULL COMMENT '输入值',
`event_type` VARCHAR(50) NULL COMMENT '事件类型',
`offset_x` INT NULL COMMENT 'X轴偏移量',
`offset_y` INT NULL COMMENT 'Y轴偏移量',
`wait_time_seconds` INT NULL COMMENT '等待时间(秒)',
`success_next_step_order` INT NULL COMMENT '成功时跳转到的步骤顺序', `success_next_step_order` INT NULL COMMENT '成功时跳转到的步骤顺序',
`failure_next_step_order` INT NULL COMMENT '失败时跳转到的步骤顺序', `failure_next_step_order` INT NULL COMMENT '失败时跳转到的步骤顺序',
`run_condition` JSON NULL COMMENT '执行条件,例如:{"previous_step_id": "step_xyz", "status": "success"}', `run_condition` JSON NULL COMMENT '执行条件,例如:{"previous_step_id": "step_xyz", "status": "success"}',
@ -66,23 +60,22 @@ ALTER TABLE composite_cases
ADD COLUMN deleted_at DATETIME NULL AFTER updated_at; ADD COLUMN deleted_at DATETIME NULL AFTER updated_at;
-- 为composite_case_steps表添加缺失的字段 -- 为composite_case_steps表添加缺失的字段
ALTER TABLE composite_case_steps
ADD COLUMN step_description TEXT NULL AFTER step_name COMMENT '步骤描述';
ALTER TABLE composite_case_steps ALTER TABLE composite_case_steps
ADD COLUMN selector VARCHAR(255) NULL AFTER is_required COMMENT 'UI元素选择器'; ADD COLUMN selector VARCHAR(255) NULL COMMENT 'UI元素选择器' AFTER is_required;
ALTER TABLE composite_case_steps ALTER TABLE composite_case_steps
ADD COLUMN input_value VARCHAR(255) NULL AFTER selector COMMENT '输入值'; ADD COLUMN input_value VARCHAR(255) NULL COMMENT '输入值' AFTER selector;
ALTER TABLE composite_case_steps ALTER TABLE composite_case_steps
ADD COLUMN event_type VARCHAR(50) NULL AFTER input_value COMMENT '事件类型'; ADD COLUMN event_type VARCHAR(50) NULL COMMENT '事件类型' AFTER input_value;
ALTER TABLE composite_case_steps ALTER TABLE composite_case_steps
ADD COLUMN offset_x INT NULL AFTER event_type COMMENT 'X轴偏移量'; ADD COLUMN offset_x INT NULL COMMENT 'X轴偏移量' AFTER event_type;
ALTER TABLE composite_case_steps ALTER TABLE composite_case_steps
ADD COLUMN offset_y INT NULL AFTER offset_x COMMENT 'Y轴偏移量'; ADD COLUMN offset_y INT NULL COMMENT 'Y轴偏移量' AFTER offset_x;
ALTER TABLE composite_case_steps ALTER TABLE composite_case_steps
ADD COLUMN wait_time_seconds INT NULL AFTER offset_y COMMENT '等待时间(秒)'; ADD COLUMN wait_time_seconds INT NULL COMMENT '等待时间(秒)' AFTER offset_y;

4
go.sum
View File

@ -99,6 +99,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@ -287,6 +289,8 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg= gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo= gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs= gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs=
gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=

View File

@ -30,6 +30,12 @@ type CompositeCaseStep struct {
ActivityName string `json:"activity_name" gorm:"not null;size:255"` ActivityName string `json:"activity_name" gorm:"not null;size:255"`
ParametersJson string `json:"parameters_json" gorm:"type:json"` ParametersJson string `json:"parameters_json" gorm:"type:json"`
IsRequired bool `json:"is_required" gorm:"default:true"` IsRequired bool `json:"is_required" gorm:"default:true"`
Selector string `json:"selector" gorm:"size:255"`
InputValue string `json:"input_value" gorm:"size:255"`
EventType string `json:"event_type" gorm:"size:50"`
OffsetX int `json:"offset_x"`
OffsetY int `json:"offset_y"`
WaitTimeSeconds int `json:"wait_time_seconds"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
} }
@ -51,6 +57,12 @@ type CreateCompositeCaseStepRequest struct {
ActivityName string `json:"activity_name"` ActivityName string `json:"activity_name"`
ParametersJson string `json:"parameters_json"` ParametersJson string `json:"parameters_json"`
IsRequired bool `json:"is_required"` IsRequired bool `json:"is_required"`
Selector string `json:"selector"`
InputValue string `json:"input_value"`
EventType string `json:"event_type"`
OffsetX int `json:"offset_x"`
OffsetY int `json:"offset_y"`
WaitTimeSeconds int `json:"wait_time_seconds"`
} }
// UpdateCompositeCaseRequest 更新复合案例请求 // UpdateCompositeCaseRequest 更新复合案例请求
@ -71,4 +83,10 @@ type UpdateCompositeCaseStepRequest struct {
ActivityName string `json:"activity_name"` ActivityName string `json:"activity_name"`
ParametersJson string `json:"parameters_json"` ParametersJson string `json:"parameters_json"`
IsRequired bool `json:"is_required"` IsRequired bool `json:"is_required"`
Selector string `json:"selector"`
InputValue string `json:"input_value"`
EventType string `json:"event_type"`
OffsetX int `json:"offset_x"`
OffsetY int `json:"offset_y"`
WaitTimeSeconds int `json:"wait_time_seconds"`
} }

View File

@ -26,14 +26,26 @@ message ApiTestRequest {
int32 expected_status_code = 6; int32 expected_status_code = 6;
} }
// UI测试步骤
message UiStep {
string name = 1; //
string selector = 2; // CSS or XPath selector for the element
string input_value = 3; // Value to input (for input events)
string event_type = 4; // "click", "input", "swipe", "wait"
int32 offset_x = 5; // Offset X for click/swipe
int32 offset_y = 6; // Offset Y for click/swipe
int32 wait_time_seconds = 7; // Time to wait after the event
}
// UI测试请求的参数 // UI测试请求的参数
message UiTestRequest { message UiTestRequest {
string test_case_id = 1; // UI测试用例ID string test_case_id = 1; // UI测试用例ID
string url_path = 2; // base_url string url_path = 2; // base_url
string browser_type = 3; // "chromium", "firefox", "webkit" string browser_type = 3; // "chromium", "firefox", "webkit"
bool headless = 4; // bool headless = 4; //
map<string, string> user_data = 5; // Worker // map<string, string> user_data = 5; // Worker
optional string browser_session_id = 6; // optional string browser_session_id = 6; //
repeated UiStep steps = 7; // UI测试步骤列表
} }
// //

View File

@ -66,6 +66,12 @@ func (s *CompositeCaseService) CreateCompositeCase(req *models.CreateCompositeCa
ActivityName: activityName, ActivityName: activityName,
ParametersJson: fixParametersJson(stepReq.ParametersJson), ParametersJson: fixParametersJson(stepReq.ParametersJson),
IsRequired: stepReq.IsRequired, IsRequired: stepReq.IsRequired,
Selector: stepReq.Selector,
InputValue: stepReq.InputValue,
EventType: stepReq.EventType,
OffsetX: stepReq.OffsetX,
OffsetY: stepReq.OffsetY,
WaitTimeSeconds: stepReq.WaitTimeSeconds,
} }
steps = append(steps, step) steps = append(steps, step)
} }
@ -190,6 +196,12 @@ func (s *CompositeCaseService) UpdateCompositeCase(id uint, req *models.UpdateCo
ActivityName: activityName, ActivityName: activityName,
ParametersJson: fixParametersJson(stepReq.ParametersJson), ParametersJson: fixParametersJson(stepReq.ParametersJson),
IsRequired: stepReq.IsRequired, IsRequired: stepReq.IsRequired,
Selector: stepReq.Selector,
InputValue: stepReq.InputValue,
EventType: stepReq.EventType,
OffsetX: stepReq.OffsetX,
OffsetY: stepReq.OffsetY,
WaitTimeSeconds: stepReq.WaitTimeSeconds,
} }
steps = append(steps, step) steps = append(steps, step)
} }

0
workers/__init__.py Normal file
View File

View File

View File

@ -12,7 +12,7 @@ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), 'gen'
from pb import common_test_pb2 as pb from pb import common_test_pb2 as pb
from api_tests import execute_api_test_case from api_tests import execute_api_test_case
from ui_tests import execute_ui_test_case, close_browser_session from ui_tests import execute_ui_test_case, close_browser_session
from utils import upload_file_to_s3, scalar_map_to_dict from workers.python.utils import upload_file_to_s3, scalar_map_to_dict
class TestActivities: class TestActivities:
@ -134,10 +134,12 @@ class TestActivities:
activity.heartbeat() activity.heartbeat()
# 调用实际的UI测试逻辑执行浏览器自动化测试并返回本地文件路径 # 调用实际的UI测试逻辑执行浏览器自动化测试并返回本地文件路径
ui_test_success, log_output, screenshot_path, html_report_path = await execute_ui_test_case( ui_test_success, log_output, screenshot_path, html_report_path, browser_session_id = await execute_ui_test_case(
req.test_case_id, req.url_path, req.browser_type, req.headless, scalar_map_to_dict(req.user_data) req.test_case_id, req.url_path, req.browser_type, req.headless, list(req.steps), req.browser_session_id
) )
result.browser_session_id = browser_session_id
# 填充基本测试结果 # 填充基本测试结果
result.base_result.success = ui_test_success result.base_result.success = ui_test_success
result.base_result.log_output = log_output result.base_result.log_output = log_output

View File

@ -9,7 +9,7 @@ from temporalio.worker import Worker
# 确保能导入 gen 模块 # 确保能导入 gen 模块
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), 'gen'))) sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), 'gen')))
from activities import TestActivities # 导入定义的 Activity from workers.python.activities import TestActivities # 导入定义的 Activity
async def main(): async def main():
# 连接 Temporal Server # 连接 Temporal Server

View File

View File

@ -5,3 +5,4 @@ grpcio-tools # 用于生成 protobuf 代码
requests # 用于API测试 requests # 用于API测试
pytest # 测试框架 pytest # 测试框架
playwright # UI自动化测试库或使用 selenium playwright # UI自动化测试库或使用 selenium
pytest-asyncio # Pytest support for asyncio

View File

View File

@ -0,0 +1,52 @@
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from workers.python.ui_tests import execute_ui_test_case
from workers.python.pb import common_test_pb2 as pb
@pytest.mark.asyncio
async def test_execute_ui_test_case_with_steps():
# 1. Setup Mocks
mock_page = AsyncMock()
mock_browser = AsyncMock()
mock_browser.new_page.return_value = mock_page
# `page.locator` is a sync method returning an object with async methods.
# So, we use MagicMock to return our AsyncMock element.
mock_element = AsyncMock()
mock_page.locator = MagicMock(return_value=mock_element)
# Patch the playwright instance and browser sessions
with patch('workers.python.ui_tests._playwright_instance', new_callable=AsyncMock) as mock_playwright:
mock_playwright.chromium.launch.return_value = mock_browser
with patch('workers.python.ui_tests._browser_sessions', new_callable=dict):
# 2. Prepare test data
steps = [
pb.UiStep(name="Click button", event_type="click", selector="#button1"),
pb.UiStep(name="Input text", event_type="input", selector="#input1", input_value="test"),
]
# 3. Call the function under test
success, log_output, _, _, _ = await execute_ui_test_case(
test_case_id="test1",
url_path="/",
browser_type="chromium",
headless=True,
steps=steps,
browser_session_id=None
)
# 4. Assertions
assert success is True, f"Test failed with logs: {log_output}"
# Assert page navigation
mock_page.goto.assert_awaited_once_with('https://playwright.dev/')
# Assert that the locator was called correctly
mock_page.locator.assert_any_call("#button1")
mock_page.locator.assert_any_call("#input1")
# Assert that the async methods on the element were awaited
mock_element.click.assert_awaited_once()
mock_element.fill.assert_awaited_once_with("test")

View File

@ -1,5 +1,5 @@
# UI 测试具体实现 (使用 Playwright) # UI 测试具体实现 (使用 Playwright)
from playwright.async_api import async_playwright, expect from playwright.async_api import async_playwright
import os import os
import datetime import datetime
import uuid import uuid
@ -9,13 +9,13 @@ from typing import Optional
_browser_sessions = {} _browser_sessions = {}
_playwright_instance = None _playwright_instance = None
async def execute_ui_test_case(test_case_id: str, url_path: str, browser_type: str, headless: bool, user_data: dict, browser_session_id: Optional[str] = None): async def execute_ui_test_case(test_case_id: str, url_path: str, browser_type: str, headless: bool, steps: list, browser_session_id: Optional[str] = None):
""" """
实际执行UI测试的函数 实际执行UI测试的函数
支持浏览器会话复用 支持浏览器会话复用
""" """
global _browser_sessions, _playwright_instance global _browser_sessions, _playwright_instance
base_url = "https://playwright.dev" # 假设 UI 测试的基地址 base_url = "" # 假设 UI 测试的基地址
full_url = f"{base_url}{url_path}" full_url = f"{base_url}{url_path}"
log_output = [] log_output = []
@ -54,26 +54,36 @@ async def execute_ui_test_case(test_case_id: str, url_path: str, browser_type: s
page = await browser.new_page() page = await browser.new_page()
# 模拟登录(如果需要)
if user_data:
log_output.append(f"Attempting to log in with user: {user_data.get('user')}")
await page.goto(f"{base_url}/login")
await page.fill('input[name="username"]', user_data.get('user', ''))
await page.fill('input[name="password"]', user_data.get('pass', ''))
await page.click('button[type="submit"]')
await page.wait_for_url(full_url)
await page.goto(full_url) await page.goto(full_url)
log_output.append(f"Navigated to: {full_url}") log_output.append(f"Navigated to: {full_url}")
# 示例UI操作和断言 # 遍历并执行所有UI步骤
element = page.locator("text=Playwright enables reliable end-to-end testing for modern web apps.") for step in steps:
await expect(element).to_be_visible() log_output.append(f"Executing step: {step.name} ({step.event_type})")
log_output.append("Found expected text on page.") element = page.locator(step.selector)
await page.click("text=Docs") if step.event_type == "click":
await page.wait_for_url("**/docs/intro") await element.click()
log_output.append("Clicked 'Docs' link and navigated.") log_output.append(f"Clicked element with selector: {step.selector}")
elif step.event_type == "input":
await element.fill(step.input_value)
log_output.append(f"Input '{step.input_value}' into element with selector: {step.selector}")
elif step.event_type == "swipe":
# Playwright doesn't have a built-in swipe, so we simulate with mouse actions
box = await element.bounding_box()
if box:
start_x = box['x'] + box['width'] / 2
start_y = box['y'] + box['height'] / 2
await page.mouse.move(start_x, start_y)
await page.mouse.down()
await page.mouse.move(start_x + step.offset_x, start_y + step.offset_y)
await page.mouse.up()
log_output.append(f"Swiped on element with selector: {step.selector}")
elif step.event_type == "wait":
await page.wait_for_timeout(step.wait_time_seconds * 1000)
log_output.append(f"Waited for {step.wait_time_seconds} seconds.")
else:
log_output.append(f"Unsupported event type: {step.event_type}")
success = True success = True
log_output.append("UI Test PASSED.") log_output.append("UI Test PASSED.")

View File

@ -86,7 +86,7 @@ func TestRunWorkflow(ctx workflow.Context, input *pb.TestRunInput) (*pb.TestRunO
UrlPath: "/dashboard", // 要测试的页面路径 UrlPath: "/dashboard", // 要测试的页面路径
BrowserType: "chromium", // 使用的浏览器类型 BrowserType: "chromium", // 使用的浏览器类型
Headless: true, // 是否使用无头模式运行浏览器 Headless: true, // 是否使用无头模式运行浏览器
UserData: map[string]string{"user": "test", "pass": "password"}, // 测试用的用户数据 //UserData: map[string]string{"user": "test", "pass": "password"}, // 测试用的用户数据
} }
// 声明变量用于接收 UI 测试的结果 // 声明变量用于接收 UI 测试的结果