From b822428a2a9ea0dc6cc2fc1b4e2d2a8366422258 Mon Sep 17 00:00:00 2001 From: xst Date: Wed, 15 Jul 2026 17:34:25 +0800 Subject: [PATCH] =?UTF-8?q?feat(mini=5Fprogram):=20=E5=B0=8F=E7=A8=8B?= =?UTF-8?q?=E5=BA=8F=E7=AB=AF=E4=BB=8E=20Playwright=20=E5=88=87=E6=8D=A2?= =?UTF-8?q?=E4=B8=BA=20miniprogram-automator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 驱动方式: Python pytest → subprocess → node runner.js → miniprogram-automator → 微信开发者工具 架构: tests/web/mini_program/automator/ (Node.js) + conftest.py (Python subprocess 桥接) 前置条件: 微信开发者工具 + 开启服务端口 + npm install + uni-app build:mp-weixin --- tests/config/test_config.yaml | 30 ++-- tests/web/mini_program/README.md | 68 ++++++++ tests/web/mini_program/automator/package.json | 11 ++ .../mini_program/automator/pages/home_page.js | 30 ++++ tests/web/mini_program/automator/runner.js | 164 ++++++++++++++++++ tests/web/mini_program/conftest.py | 108 ++++++++---- tests/web/mini_program/pages/__init__.py | 31 ---- .../web/mini_program/pages/mini_home_page.py | 24 --- tests/web/mini_program/tests/test_smoke.py | 39 ++++- 9 files changed, 393 insertions(+), 112 deletions(-) create mode 100644 tests/web/mini_program/README.md create mode 100644 tests/web/mini_program/automator/package.json create mode 100644 tests/web/mini_program/automator/pages/home_page.js create mode 100644 tests/web/mini_program/automator/runner.js delete mode 100644 tests/web/mini_program/pages/__init__.py delete mode 100644 tests/web/mini_program/pages/mini_home_page.py diff --git a/tests/config/test_config.yaml b/tests/config/test_config.yaml index 3dc741b..dbd604d 100644 --- a/tests/config/test_config.yaml +++ b/tests/config/test_config.yaml @@ -28,24 +28,22 @@ admin: storage_state_dir: "tests/web/auth" # ═══════════════════════════════════════════════════════════════════ -# ── 司机端小程序 (Web/Playwright,模拟微信 WebView) ── +# ── 司机端小程序 (miniprogram-automator / Node.js) ── # ═══════════════════════════════════════════════════════════════════ +# 原理: Python pytest → subprocess → node runner.js → miniprogram-automator +# 操控微信开发者工具中的小程序,返回 JSON 结果 +# +# 前置条件: +# 1. 安装微信开发者工具 + 开启服务端口(设置 → 安全 → 服务端口) +# 2. uni-app 编译为微信小程序: npm run build:mp-weixin +# 3. 安装依赖: cd tests/web/mini_program/automator && npm install mini_program: - base_url: "https://ybxcx.ynyun8.com:8000/mini" # ⚠️ 请确认小程序 WebView 实际地址 + # uni-app 编译出的小程序项目路径(.js/.json/.wxml 所在目录) + project_path: "E:/test/QaAutomationHub/mini_program/dist" # ⚠️ 请确认实际路径 + # 微信开发者工具 CLI 路径(Windows 默认如下,macOS 为 /Applications/...) + dev_tool_path: "C:/Program Files (x86)/Tencent/微信web开发者工具/cli.bat" timeout_seconds: 120 screenshot_on_failure: true - screenshot_on_step: false - browsers: - - chromium - # 模拟微信内置浏览器 UA - user_agent: "Mozilla/5.0 MicroMessenger/8.0.0" - viewport: - width: 375 - height: 812 - - # 小程序本质是 WebView,同样支持 storage_state 复用 - skip_login: true - storage_state_dir: "tests/web/auth" # ═══════════════════════════════════════════════════════════════════ # ── 司机安卓端 (App/Appium) ── @@ -74,8 +72,8 @@ shipper_android: skip_login: true device_name: "Android Emulator" - app_package: "com.yunba.shipper" # ⚠️ 请确认实际包名 - app_activity: ".MainActivity" # ⚠️ 请确认实际启动 Activity + app_package: "com.arpa.ynChengGangShipper" # ⚠️ 请确认实际包名 + app_activity: "com.arpa.wuche_shipper.MainActivity" # ⚠️ 请确认实际启动 Activity no_reset: true new_command_timeout: 120 diff --git a/tests/web/mini_program/README.md b/tests/web/mini_program/README.md new file mode 100644 index 0000000..af4eb33 --- /dev/null +++ b/tests/web/mini_program/README.md @@ -0,0 +1,68 @@ +# 司机端小程序 — miniprogram-automator + +## 环境搭建 + +### 1. 安装微信开发者工具 + +- [下载地址](https://developers.weixin.qq.com/miniprogram/dev/devtools/download.html) +- 安装后打开**设置 → 安全 → 安全**,开启**服务端口** + +### 2. 安装 Node.js 依赖 + +```bash +cd tests/web/mini_program/automator +npm install +``` + +### 3. 编译 uni-app 为微信小程序 + +```bash +# 回到项目根目录(即 uni-app 源码所在目录) +npm run build:mp-weixin +``` + +### 4. 配置 test_config.yaml + +```yaml +mini_program: + project_path: "E:/path/to/your/uniapp/dist/dev/mp-weixin" # uni-app 编译产物目录 + dev_tool_path: "C:/Program Files (x86)/Tencent/微信web开发者工具/cli.bat" +``` + +## 运行 + +```bash +pytest tests/web/mini_program/ -v -m p0 +``` + +## 原理 + +``` +pytest 用例 + ↓ conftest.py: subprocess + ↓ +node runner.js --action smoke --project-path ... + ↓ miniprogram-automator API + ↓ +微信开发者工具(开启服务端口) + ↓ +小程序运行 + ↓ +返回 JSON { ok, message, screenshot } + ↓ +Python 断言 +``` + +## 可用 actions + +| action | 参数 | 说明 | +| :--- | :--- | :--- | +| `smoke` | — | 启动小程序并检查首页 | +| `navigate` | `page=/pages/xxx/xxx` | 导航到指定页面 | +| `element` | `element-action=tap/exists` `selector=.class` | 操作/检查元素 | + +## 注意事项 + +- **登录**:首次在开发者工具中手动登录,后续 `miniprogram-automator` 启动默认复用登录态 +- **截图**:输出到 `output/screenshots/mini_program/` +- **CI**:微信开发者工具必须安装在 CI 机器上,且需要 GUI 环境(不能纯 headless) diff --git a/tests/web/mini_program/automator/package.json b/tests/web/mini_program/automator/package.json new file mode 100644 index 0000000..d215eb0 --- /dev/null +++ b/tests/web/mini_program/automator/package.json @@ -0,0 +1,11 @@ +{ + "name": "yunba-miniprogram-automator", + "description": "运八司机端小程序自动化测试 — miniprogram-automator 封装", + "private": true, + "scripts": { + "test": "node runner.js" + }, + "dependencies": { + "miniprogram-automator": "^0.10.0" + } +} diff --git a/tests/web/mini_program/automator/pages/home_page.js b/tests/web/mini_program/automator/pages/home_page.js new file mode 100644 index 0000000..84c5501 --- /dev/null +++ b/tests/web/mini_program/automator/pages/home_page.js @@ -0,0 +1,30 @@ +/** 小程序 Page Object(Node 版)— 首页 */ + +class HomePage { + constructor(miniProgram) { + this.mp = miniProgram; + } + + async goToOrderList() { + const page = await this.mp.currentPage(); + const el = await page.$(".order-entry, .order-item"); + if (el) await el.tap(); + await this.mp.waitFor(1000); + } + + async goToMine() { + const page = await this.mp.currentPage(); + const el = await page.$(".mine-tab, .my-entry"); + if (el) await el.tap(); + await this.mp.waitFor(1000); + } + + async screenshot(name) { + const path = require("path"); + const file = path.resolve(__dirname, `../../../../output/screenshots/mini_program/${name}_${Date.now()}.png`); + await this.mp.screenshot({ path: file }); + return file; + } +} + +module.exports = { HomePage }; diff --git a/tests/web/mini_program/automator/runner.js b/tests/web/mini_program/automator/runner.js new file mode 100644 index 0000000..c558a42 --- /dev/null +++ b/tests/web/mini_program/automator/runner.js @@ -0,0 +1,164 @@ +/** + * 运八司机端小程序 — miniprogram-automator 测试执行器。 + * + * 由 Python conftest.py 通过 subprocess 驱动,接收 JSON 指令,返回 JSON 结果。 + * + * 用法: + * node runner.js --action smoke --project-path "E:/xxx/dist" + * + * 返回 JSON: + * { "ok": true/false, "message": "...", "screenshot": "path/to.png" } + */ + +const automator = require("miniprogram-automator"); +const path = require("path"); +const fs = require("fs"); +const { execSync } = require("child_process"); + +// ═══════════════════════════════════════════════════════════════ +// 参数解析 +// ═══════════════════════════════════════════════════════════════ +const args = process.argv.slice(2); +function getArg(name) { + const idx = args.indexOf(`--${name}`); + return idx >= 0 ? args[idx + 1] : null; +} + +const ACTION = getArg("action") || "smoke"; +const PROJECT_PATH = getArg("project-path") || "."; +const DEV_TOOL_PATH = + getArg("dev-tool-path") || "C:/Program Files (x86)/Tencent/微信web开发者工具/cli.bat"; +const TIMEOUT = parseInt(getArg("timeout")) || 30000; + +const SCREENSHOTS_DIR = path.resolve(__dirname, "../../../../output/screenshots/mini_program"); +fs.mkdirSync(SCREENSHOTS_DIR, { recursive: true }); + +// ═══════════════════════════════════════════════════════════════ +// 辅助 +// ═══════════════════════════════════════════════════════════════ +function ok(msg, screenshot) { + console.log(JSON.stringify({ ok: true, message: msg, screenshot: screenshot || null })); + process.exit(0); +} + +function fail(msg, screenshot) { + console.error(JSON.stringify({ ok: false, message: msg, screenshot: screenshot || null })); + process.exit(1); +} + +async function screenshot(miniProgram, name) { + const file = path.join(SCREENSHOTS_DIR, `${name}_${Date.now()}.png`); + await miniProgram.screenshot({ path: file }); + return file; +} + +// ═══════════════════════════════════════════════════════════════ +// Actions +// ═══════════════════════════════════════════════════════════════ + +async function actionSmoke() { + let miniProgram; + try { + miniProgram = await automator.launch({ + projectPath: PROJECT_PATH, + cliPath: DEV_TOOL_PATH, + }); + + // 等小程序启动 + await miniProgram.waitFor(5000); + + // 检查是否启动成功 + const currentPage = await miniProgram.currentPage(); + if (!currentPage) { + return fail("小程序启动后未获取到页面", null); + } + + // 验证首页元素 + const elements = await currentPage.$$("view"); + const ss = await screenshot(miniProgram, "smoke_home"); + + return ok(`首页加载成功,检测到 ${elements.length} 个 view 元素`, ss); + } catch (e) { + return fail(`小程序启动失败: ${e.message}`, null); + } finally { + if (miniProgram) await miniProgram.close(); + } +} + +async function actionNavigate(pagePath) { + let miniProgram; + try { + miniProgram = await automator.launch({ + projectPath: PROJECT_PATH, + cliPath: DEV_TOOL_PATH, + }); + await miniProgram.waitFor(3000); + + const currentPage = await miniProgram.currentPage(); + await currentPage.callMethod("navigateTo", { url: pagePath }); + await miniProgram.waitFor(2000); + + const newPage = await miniProgram.currentPage(); + const ss = await screenshot(miniProgram, `nav_${pagePath.replace(/\//g, "_")}`); + const currentPath = await newPage.path(); + + return ok(`导航到 ${pagePath},当前路径: ${currentPath}`, ss); + } catch (e) { + return fail(`页面导航失败 ${pagePath}: ${e.message}`, null); + } finally { + if (miniProgram) await miniProgram.close(); + } +} + +async function actionElement(actionType, selector) { + let miniProgram; + try { + miniProgram = await automator.launch({ + projectPath: PROJECT_PATH, + cliPath: DEV_TOOL_PATH, + }); + await miniProgram.waitFor(3000); + + const currentPage = await miniProgram.currentPage(); + + if (actionType === "exists") { + const el = await currentPage.$(selector); + const exists = el !== null; + const ss = exists ? null : await screenshot(miniProgram, `missing_${selector}`); + return ok(exists ? `元素 "${selector}" 存在` : `元素 "${selector}" 不存在`, ss); + } + + if (actionType === "tap") { + const el = await currentPage.$(selector); + if (!el) { + return fail(`元素 "${selector}" 不存在,无法点击`, null); + } + await el.tap(); + await miniProgram.waitFor(1000); + const ss = await screenshot(miniProgram, `tap_${selector}`); + return ok(`点击 "${selector}" 成功`, ss); + } + + return fail(`未知子操作: ${actionType}`, null); + } catch (e) { + return fail(`操作失败 ${actionType}/${selector}: ${e.message}`, null); + } finally { + if (miniProgram) await miniProgram.close(); + } +} + +// ═══════════════════════════════════════════════════════════════ +// 入口 +// ═══════════════════════════════════════════════════════════════ +(async () => { + switch (ACTION) { + case "smoke": + return actionSmoke(); + case "navigate": + return actionNavigate(getArg("page") || "/pages/index/index"); + case "element": + return actionElement(getArg("element-action") || "exists", getArg("selector") || "view"); + default: + return fail(`未知 action: ${ACTION}`, null); + } +})(); diff --git a/tests/web/mini_program/conftest.py b/tests/web/mini_program/conftest.py index 1540b00..608eab6 100644 --- a/tests/web/mini_program/conftest.py +++ b/tests/web/mini_program/conftest.py @@ -1,52 +1,90 @@ -"""司机端小程序 fixture — Playwright(模拟微信 WebView)。 +"""司机端小程序 fixture — 通过 subprocess 驱动 miniprogram-automator (Node.js)。 -核心机制: - 模拟微信内置浏览器 UA,微信 WebView 仍需登录态。 - 首次使用 python tests/web/admin/save_auth.py 生成后用在此端也可以。 +原理: + Python pytest → subprocess → node runner.js → miniprogram-automator + → 微信开发者工具 → 小程序 + 返回 JSON: {"ok": true/false, "message": "...", "screenshot": "path"} + +前置条件: + 1. 微信开发者工具已安装 + 开启服务端口 + 2. npm install(在 tests/web/mini_program/automator/ 下) + 3. uni-app 已编译为微信小程序 """ +import json +import subprocess from datetime import datetime from pathlib import Path import pytest -from playwright.sync_api import sync_playwright, Page +PROJECT_ROOT = Path(__file__).parent.parent.parent.parent +RUNNER = Path(__file__).parent / "automator" / "runner.js" SCREENSHOTS_DIR = Path("output/screenshots") +def _run_runner(project_path: str, action: str, **kwargs) -> dict: + """调用 node runner.js,返回解析后的 JSON。""" + cmd = [ + "node", str(RUNNER), + "--action", action, + "--project-path", project_path, + "--timeout", str(kwargs.get("timeout", 30000)), + ] + for key, value in kwargs.items(): + if value is not None and key not in ("timeout",): + cmd.extend([f"--{key.replace('_', '-')}", str(value)]) + + result = subprocess.run(cmd, capture_output=True, text=True, timeout=120, cwd=str(PROJECT_ROOT)) + + try: + data = json.loads(result.stdout.strip()) + except json.JSONDecodeError: + data = {"ok": False, "message": result.stderr or result.stdout, "screenshot": None} + + data["_raw_stdout"] = result.stdout + data["_raw_stderr"] = result.stderr + return data + + @pytest.fixture -def page(mini_program_config, request) -> Page: - """微信 WebView 模拟 Page。""" - viewport = mini_program_config.get("viewport", {"width": 375, "height": 812}) - timeout = mini_program_config.get("timeout_seconds", 120) * 1000 - user_agent = mini_program_config.get("user_agent", "Mozilla/5.0 MicroMessenger/8.0.0") - base_url = mini_program_config["base_url"] +def mini_program_config(config): + """小程序配置。""" + return config["mini_program"] - with sync_playwright() as p: - browser = p.chromium.launch(headless=True) - context = browser.new_context( - viewport={"width": viewport["width"], "height": viewport["height"]}, - user_agent=user_agent, - locale="zh-CN", + +@pytest.fixture +def run_mini_action(mini_program_config): + """调用 miniprogram-automator 执行一个操作并返回结果。 + + Usage: + result = run_mini_action("smoke") + assert result["ok"], result["message"] + # result["screenshot"] 是截图路径 + """ + + def _run(action: str, **kwargs) -> dict: + project_path = mini_program_config.get("project_path", ".") + dev_tool = mini_program_config.get("dev_tool_path", "") + timeout = mini_program_config.get("timeout_seconds", 120) * 1000 + + return _run_runner( + project_path, + action, + dev_tool_path=dev_tool, + timeout=timeout, + **kwargs, ) - page = context.new_page() - page.set_default_timeout(timeout) - yield page - - if hasattr(request.node, "rep_call") and request.node.rep_call.failed: - scenario_dir = SCREENSHOTS_DIR / request.node.name - scenario_dir.mkdir(parents=True, exist_ok=True) - ts = datetime.now().strftime("%Y%m%d_%H%M%S") - path = str(scenario_dir / f"FAIL_mini_{ts}.png") - page.screenshot(path=path) - - context.close() - browser.close() + return _run -@pytest.hookimpl(tryfirst=True, hookwrapper=True) -def pytest_runtest_makereport(item, call): - outcome = yield - rep = outcome.get_result() - setattr(item, f"rep_{rep.when}", rep) +def save_failure_screenshot(name: str) -> str: + """保存 Python 端的失败截图(如 runner 调用超时)。""" + scenario_dir = SCREENSHOTS_DIR / "mini_program" + scenario_dir.mkdir(parents=True, exist_ok=True) + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + path = str(scenario_dir / f"FAIL_{name}_{ts}.png") + # 此层无法截图小程序,创建空标记文件用于日志追溯 + Path(path).touch() + return path diff --git a/tests/web/mini_program/pages/__init__.py b/tests/web/mini_program/pages/__init__.py deleted file mode 100644 index 3f3d48e..0000000 --- a/tests/web/mini_program/pages/__init__.py +++ /dev/null @@ -1,31 +0,0 @@ -"""小程序 Page Object 基类。""" - -from datetime import datetime -from pathlib import Path - -from playwright.sync_api import Page, Locator, expect - -SCREENSHOTS_DIR = Path("output/screenshots") - - -class MiniProgramBasePage: - """小程序 Page Object 基类。""" - - def __init__(self, page: Page, timeout_ms: int = 30_000): - self.page = page - self.timeout = timeout_ms - - def wait_visible(self, locator: Locator, timeout_ms: int | None = None) -> None: - expect(locator).to_be_visible(timeout=timeout_ms or self.timeout) - - def click_when_ready(self, locator: Locator) -> None: - self.wait_visible(locator) - locator.click() - - def screenshot(self, name: str) -> str: - scenario_dir = SCREENSHOTS_DIR / self.__class__.__name__ - scenario_dir.mkdir(parents=True, exist_ok=True) - ts = datetime.now().strftime("%Y%m%d_%H%M%S") - path = str(scenario_dir / f"{name}_{ts}.png") - self.page.screenshot(path=path) - return path diff --git a/tests/web/mini_program/pages/mini_home_page.py b/tests/web/mini_program/pages/mini_home_page.py deleted file mode 100644 index fded447..0000000 --- a/tests/web/mini_program/pages/mini_home_page.py +++ /dev/null @@ -1,24 +0,0 @@ -"""小程序首页 — Page Object。 - -Usage: - from tests.web.mini_program.pages import MiniProgramBasePage -""" - -from playwright.sync_api import Page -from tests.web.mini_program.pages import MiniProgramBasePage - - -class MiniHomePage(MiniProgramBasePage): - """司机端小程序首页。""" - - def __init__(self, page: Page): - super().__init__(page) - # ⚠️ 定位器需根据实际小程序 UI 确认 - self.order_list_tab = page.get_by_text("运单") - self.mine_tab = page.get_by_text("我的") - - def go_to_order_list(self) -> None: - self.click_when_ready(self.order_list_tab) - - def go_to_mine(self) -> None: - self.click_when_ready(self.mine_tab) diff --git a/tests/web/mini_program/tests/test_smoke.py b/tests/web/mini_program/tests/test_smoke.py index 722cdbb..ccff097 100644 --- a/tests/web/mini_program/tests/test_smoke.py +++ b/tests/web/mini_program/tests/test_smoke.py @@ -1,4 +1,8 @@ -"""小程序冒烟测试。""" +"""小程序冒烟测试 — 通过 miniprogram-automator 驱动。 + +用法: + pytest tests/web/mini_program/ -m p0 +""" import pytest @@ -6,11 +10,34 @@ pytestmark = [pytest.mark.mini_program] class TestMiniProgramSmoke: - """小程序基础验证。""" + """小程序启动 + 基础页面验证。""" @pytest.mark.p0 @pytest.mark.smoke - def test_mini_program_accessible(self, page, mini_program_config): - """验证小程序首页可正常加载。""" - page.goto(mini_program_config["base_url"]) - assert page.title(), "小程序首页应显示页面标题" + def test_mini_program_launches(self, run_mini_action): + """验证小程序能正常启动并展示首页。""" + result = run_mini_action("smoke") + assert result["ok"], f"小程序启动失败: {result.get('message')}" + + @pytest.mark.p0 + @pytest.mark.smoke + def test_navigate_to_pages(self, run_mini_action): + """验证关键页面可正常导航。""" + pages = ["/pages/order/index", "/pages/mine/index"] + for p in pages: + result = run_mini_action("navigate", page=p) + assert result["ok"], f"导航到 {p} 失败: {result.get('message')}" + + +class TestMiniProgramElements: + """关键元素验证。""" + + @pytest.mark.p1 + @pytest.mark.parametrize("selector,label", [ + pytest.param(".order-entry", "运单入口", id="order_entry"), + pytest.param(".mine-tab", "我的tab", id="mine_tab"), + ]) + def test_key_element_exists(self, run_mini_action, selector, label): + """验证关键 UI 元素存在。""" + result = run_mini_action("element", element_action="exists", selector=selector) + assert result["ok"], f"{label} ({selector}) 不存在: {result.get('message')}"