From de3972577c2ac778ac795101e8c07d69cca303e5 Mon Sep 17 00:00:00 2001 From: longpeng Date: Mon, 30 Jun 2025 00:04:49 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9EUI=E6=B5=8B=E8=AF=95=E6=AD=A5?= =?UTF-8?q?=E9=AA=A4=E6=94=AF=E6=8C=81=EF=BC=8C=E4=BC=98=E5=8C=96execute?= =?UTF-8?q?=5Fui=5Ftest=5Fcase=E5=87=BD=E6=95=B0=EF=BC=8C=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E7=9B=B8=E5=85=B3=E6=95=B0=E6=8D=AE=E7=BB=93=E6=9E=84?= =?UTF-8?q?=E5=92=8C=E6=95=B0=E6=8D=AE=E5=BA=93=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 +- docs/init.sql | 21 ++++------- go.sum | 4 +++ models/composite.go | 18 ++++++++++ proto/common_test.proto | 14 +++++++- services/composite.go | 12 +++++++ workers/__init__.py | 0 workers/python/__init__.py | 0 workers/python/activities.py | 8 +++-- workers/python/main.py | 2 +- workers/python/pb/__init__.py | 0 workers/python/requirements.txt | 3 +- workers/python/tests/__init__.py | 0 workers/python/tests/test_ui_tests.py | 52 +++++++++++++++++++++++++++ workers/python/ui_tests.py | 48 +++++++++++++++---------- workflows/workflow.go | 10 +++--- 16 files changed, 149 insertions(+), 45 deletions(-) create mode 100644 workers/__init__.py create mode 100644 workers/python/__init__.py create mode 100644 workers/python/pb/__init__.py create mode 100644 workers/python/tests/__init__.py create mode 100644 workers/python/tests/test_ui_tests.py diff --git a/.gitignore b/.gitignore index c188fdd..a75cbc8 100644 --- a/.gitignore +++ b/.gitignore @@ -183,5 +183,5 @@ cython_debug/ *pb2.py* *pb.go -*.sql +#*.sql *.yaml \ No newline at end of file diff --git a/docs/init.sql b/docs/init.sql index f503ec1..56ac933 100644 --- a/docs/init.sql +++ b/docs/init.sql @@ -44,12 +44,6 @@ CREATE TABLE `composite_case_steps` `parameters_json` JSON NULL COMMENT '步骤特定参数,例如:{"endpoint": "/users", "method": "GET"}', `is_required` BOOLEAN DEFAULT TRUE 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 '成功时跳转到的步骤顺序', `failure_next_step_order` INT NULL COMMENT '失败时跳转到的步骤顺序', `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; -- 为composite_case_steps表添加缺失的字段 -ALTER TABLE composite_case_steps - ADD COLUMN step_description TEXT NULL AFTER step_name COMMENT '步骤描述'; + 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 - 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 - 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 - 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 - 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 - ADD COLUMN wait_time_seconds INT NULL AFTER offset_y COMMENT '等待时间(秒)'; + ADD COLUMN wait_time_seconds INT NULL COMMENT '等待时间(秒)' AFTER offset_y; \ No newline at end of file diff --git a/go.sum b/go.sum index 1b2da48..8aaaa90 100644 --- a/go.sum +++ b/go.sum @@ -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/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-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-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 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= 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/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/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/models/composite.go b/models/composite.go index 4a39024..3c09193 100644 --- a/models/composite.go +++ b/models/composite.go @@ -30,6 +30,12 @@ type CompositeCaseStep struct { ActivityName string `json:"activity_name" gorm:"not null;size:255"` ParametersJson string `json:"parameters_json" gorm:"type:json"` 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"` UpdatedAt time.Time `json:"updated_at"` } @@ -51,6 +57,12 @@ type CreateCompositeCaseStepRequest struct { ActivityName string `json:"activity_name"` ParametersJson string `json:"parameters_json"` 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 更新复合案例请求 @@ -71,4 +83,10 @@ type UpdateCompositeCaseStepRequest struct { ActivityName string `json:"activity_name"` ParametersJson string `json:"parameters_json"` 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"` } diff --git a/proto/common_test.proto b/proto/common_test.proto index 030e900..40ae5c4 100644 --- a/proto/common_test.proto +++ b/proto/common_test.proto @@ -26,14 +26,26 @@ message ApiTestRequest { 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测试请求的参数 message UiTestRequest { string test_case_id = 1; // UI测试用例ID string url_path = 2; // 相对于 base_url 的路径 string browser_type = 3; // "chromium", "firefox", "webkit" bool headless = 4; // 是否无头模式 - map user_data = 5; // 用户名、密码等敏感信息,不建议直接传输,建议 Worker 端获取 +// map user_data = 5; // 用户名、密码等敏感信息,不建议直接传输,建议 Worker 端获取 optional string browser_session_id = 6; // 可选,用于复用现有浏览器会话 + repeated UiStep steps = 7; // 新增:UI测试步骤列表 } // 请求关闭浏览器会话 diff --git a/services/composite.go b/services/composite.go index 98b8b4c..0bcd5fe 100644 --- a/services/composite.go +++ b/services/composite.go @@ -66,6 +66,12 @@ func (s *CompositeCaseService) CreateCompositeCase(req *models.CreateCompositeCa ActivityName: activityName, ParametersJson: fixParametersJson(stepReq.ParametersJson), 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) } @@ -190,6 +196,12 @@ func (s *CompositeCaseService) UpdateCompositeCase(id uint, req *models.UpdateCo ActivityName: activityName, ParametersJson: fixParametersJson(stepReq.ParametersJson), 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) } diff --git a/workers/__init__.py b/workers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/workers/python/__init__.py b/workers/python/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/workers/python/activities.py b/workers/python/activities.py index ecdf948..b12275c 100644 --- a/workers/python/activities.py +++ b/workers/python/activities.py @@ -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 api_tests import execute_api_test_case 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: @@ -134,10 +134,12 @@ class TestActivities: activity.heartbeat() # 调用实际的UI测试逻辑,执行浏览器自动化测试并返回本地文件路径 - ui_test_success, log_output, screenshot_path, html_report_path = await execute_ui_test_case( - req.test_case_id, req.url_path, req.browser_type, req.headless, scalar_map_to_dict(req.user_data) + 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, list(req.steps), req.browser_session_id ) + result.browser_session_id = browser_session_id + # 填充基本测试结果 result.base_result.success = ui_test_success result.base_result.log_output = log_output diff --git a/workers/python/main.py b/workers/python/main.py index 3427069..12d29c0 100644 --- a/workers/python/main.py +++ b/workers/python/main.py @@ -9,7 +9,7 @@ from temporalio.worker import Worker # 确保能导入 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(): # 连接 Temporal Server diff --git a/workers/python/pb/__init__.py b/workers/python/pb/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/workers/python/requirements.txt b/workers/python/requirements.txt index fd7c77a..3f70141 100644 --- a/workers/python/requirements.txt +++ b/workers/python/requirements.txt @@ -4,4 +4,5 @@ protobuf grpcio-tools # 用于生成 protobuf 代码 requests # 用于API测试 pytest # 测试框架 -playwright # UI自动化测试库,或使用 selenium \ No newline at end of file +playwright # UI自动化测试库,或使用 selenium +pytest-asyncio # Pytest support for asyncio \ No newline at end of file diff --git a/workers/python/tests/__init__.py b/workers/python/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/workers/python/tests/test_ui_tests.py b/workers/python/tests/test_ui_tests.py new file mode 100644 index 0000000..e3a5ed6 --- /dev/null +++ b/workers/python/tests/test_ui_tests.py @@ -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") diff --git a/workers/python/ui_tests.py b/workers/python/ui_tests.py index a7f53f6..5411c79 100644 --- a/workers/python/ui_tests.py +++ b/workers/python/ui_tests.py @@ -1,5 +1,5 @@ # UI 测试具体实现 (使用 Playwright) -from playwright.async_api import async_playwright, expect +from playwright.async_api import async_playwright import os import datetime import uuid @@ -9,13 +9,13 @@ from typing import Optional _browser_sessions = {} _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测试的函数。 支持浏览器会话复用。 """ global _browser_sessions, _playwright_instance - base_url = "https://playwright.dev" # 假设 UI 测试的基地址 + base_url = "" # 假设 UI 测试的基地址 full_url = f"{base_url}{url_path}" 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() - # 模拟登录(如果需要) - 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) log_output.append(f"Navigated to: {full_url}") - # 示例UI操作和断言 - element = page.locator("text=Playwright enables reliable end-to-end testing for modern web apps.") - await expect(element).to_be_visible() - log_output.append("Found expected text on page.") + # 遍历并执行所有UI步骤 + for step in steps: + log_output.append(f"Executing step: {step.name} ({step.event_type})") + element = page.locator(step.selector) - await page.click("text=Docs") - await page.wait_for_url("**/docs/intro") - log_output.append("Clicked 'Docs' link and navigated.") + if step.event_type == "click": + await element.click() + 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 log_output.append("UI Test PASSED.") diff --git a/workflows/workflow.go b/workflows/workflow.go index d414aa9..70d1726 100644 --- a/workflows/workflow.go +++ b/workflows/workflow.go @@ -82,11 +82,11 @@ func TestRunWorkflow(ctx workflow.Context, input *pb.TestRunInput) (*pb.TestRunO // 构造 UI 测试的请求参数 // 包含浏览器配置和测试页面信息 uiTestInput := &pb.UiTestRequest{ - TestCaseId: "ui-example-1", // UI 测试用例标识 - UrlPath: "/dashboard", // 要测试的页面路径 - BrowserType: "chromium", // 使用的浏览器类型 - Headless: true, // 是否使用无头模式运行浏览器 - UserData: map[string]string{"user": "test", "pass": "password"}, // 测试用的用户数据 + TestCaseId: "ui-example-1", // UI 测试用例标识 + UrlPath: "/dashboard", // 要测试的页面路径 + BrowserType: "chromium", // 使用的浏览器类型 + Headless: true, // 是否使用无头模式运行浏览器 + //UserData: map[string]string{"user": "test", "pass": "password"}, // 测试用的用户数据 } // 声明变量用于接收 UI 测试的结果