@witon/laozhu_mp_e2e_testkit 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,123 @@
1
+ # laozhu_mp_e2e_testkit
2
+
3
+ 微信小程序 E2E 自动化测试通用套件:**automator 统一配置**、**日志转发与错误检测**、**测试规范文档**、**工具助手**。用例与业务逻辑由各项目自行实现。
4
+
5
+ ## 安装
6
+
7
+ 在项目根目录:
8
+
9
+ ```bash
10
+ # 从 npm 安装
11
+ npm install @witon/laozhu_mp_e2e_testkit
12
+
13
+ # 或从 Git
14
+ npm install git+https://github.com/your-org/laozhu_mp_e2e_testkit.git
15
+
16
+ # 或本地路径(开发/未发布时)
17
+ npm install file:./path/to/laozhu_mp_e2e_testkit
18
+ ```
19
+
20
+ 项目需已安装 `miniprogram-automator` 与 Jest(或其它测试运行器)。从 npm 安装时,代码中引用包名均为 `@witon/laozhu_mp_e2e_testkit`。
21
+
22
+ ## 配置
23
+
24
+ ### automator 统一配置(projectPath / cliPath)
25
+
26
+ 在 `beforeAll` 中获取配置并启动小程序:
27
+
28
+ ```js
29
+ const automator = require('miniprogram-automator');
30
+ const { getAutomatorConfig, installTestLogger } = require('@witon/laozhu_mp_e2e_testkit');
31
+ const path = require('path');
32
+
33
+ beforeAll(async () => {
34
+ const config = getAutomatorConfig({
35
+ projectPath: path.resolve(__dirname, '../..'), // 小程序项目根路径
36
+ // cliPath: '...', // 可选,默认用环境变量 WECHAT_CLI_PATH
37
+ });
38
+ miniProgram = await automator.launch({
39
+ cliPath: config.cliPath,
40
+ projectPath: config.projectPath,
41
+ });
42
+ await installTestLogger(miniProgram);
43
+ }, 300000);
44
+ ```
45
+
46
+ **环境变量(可选):**
47
+
48
+ - `MINIPROGRAM_PROJECT_PATH` 或 `E2E_PROJECT_PATH`:小程序项目根路径,不设则必须在调用 `getAutomatorConfig(overrides)` 时传入 `projectPath`
49
+ - `WECHAT_CLI_PATH`:微信开发者工具 CLI 路径
50
+
51
+ ### TabBar 路径常量
52
+
53
+ 本包不包含 `TAB_BAR_PATHS`,由项目在本地定义并与此包的 `navigateTo`、`switchTab`、`navigateBack` 一起使用,例如:
54
+
55
+ ```js
56
+ // test/setup/tabBarPaths.js
57
+ const { navigateTo, switchTab, navigateBack } = require('@witon/laozhu_mp_e2e_testkit');
58
+
59
+ const TAB_BAR_PATHS = {
60
+ INDEX: '/pages/index/index',
61
+ MINE: '/pages/mine/index',
62
+ };
63
+
64
+ module.exports = {
65
+ navigateTo,
66
+ switchTab,
67
+ navigateBack,
68
+ TAB_BAR_PATHS,
69
+ };
70
+ ```
71
+
72
+ ## 使用
73
+
74
+ 从包中按需引用:
75
+
76
+ ```js
77
+ const {
78
+ getAutomatorConfig,
79
+ installTestLogger,
80
+ startErrorCapture,
81
+ checkHadError,
82
+ debugLog,
83
+ waitForElement,
84
+ wait,
85
+ findButtonByText,
86
+ navigateTo,
87
+ switchTab,
88
+ scrollToSelector,
89
+ triggerPickerChange,
90
+ } = require('@witon/laozhu_mp_e2e_testkit');
91
+ ```
92
+
93
+ 或从子路径引用(便于 tree-shaking 或按需加载):
94
+
95
+ ```js
96
+ const { getAutomatorConfig } = require('@witon/laozhu_mp_e2e_testkit/src/config/automatorConfig');
97
+ const { installTestLogger, startErrorCapture, checkHadError } = require('@witon/laozhu_mp_e2e_testkit/src/logger/testLogger');
98
+ const { waitForElement, wait } = require('@witon/laozhu_mp_e2e_testkit/src/utils/waitHelpers');
99
+ ```
100
+
101
+ ## 文档
102
+
103
+ - [自动化测试实现原则](docs/AUTOMATION_TEST_PRINCIPLES.md)
104
+ - [自动化测试日志原则](docs/LOGGING_PRINCIPLES.md)
105
+
106
+ ## 提供能力概览
107
+
108
+ | 类别 | 内容 |
109
+ |----------|------|
110
+ | 统一配置 | `getAutomatorConfig(overrides)`、`checkCliExists()` |
111
+ | 日志 | `installTestLogger`、`startErrorCapture`、`checkHadError`、`debugLog`、`debugWait`、`debugConfig` |
112
+ | 等待 | `wait`、`waitFor`、`waitForElement`、`waitForAnyElement`、`waitForPagePath`、`waitForPageToLeave`、`waitForTextInPage`、`waitForTextNotInPage` |
113
+ | 页面 | `findElement`、`findElements`、`findButtonByText`、`clickElement`、`inputText`、`elementExists`、`getElementText`、`scrollToSelector`、`scrollToElement` 等 |
114
+ | 导航 | `navigateTo`、`switchTab`、`navigateBack`(无 `TAB_BAR_PATHS`,由项目定义) |
115
+ | 表单 | `triggerPickerChange` |
116
+
117
+ ## 构建与发布
118
+
119
+ 构建与发布到 npm(公开)的步骤见 [PUBLISH.md](PUBLISH.md)。
120
+
121
+ ## License
122
+
123
+ MIT
@@ -0,0 +1,179 @@
1
+ # 自动化测试实现原则
2
+
3
+ 以 `test/integration/competitionFlow.test.js` 为范本,新建或扩展自动化测试用例时应遵循以下原则。
4
+
5
+ ---
6
+
7
+ ## 1. 分层辅助架构
8
+
9
+ ### 1.1 通用工具 (`test/utils/`)
10
+
11
+ 将可复用的操作抽取到 `test/utils/` 下的辅助模块:
12
+
13
+ - **pageHelpers**:元素查找、点击、输入等与页面无关的通用操作
14
+ - **waitHelpers**:`wait`、`waitFor`、`waitForElement`、`waitForAnyElement`、`waitForPagePath` 等异步等待
15
+ - **navigationHelpers**:`navigateTo`、`switchTab`、`TAB_BAR_PATHS` 等导航能力
16
+ - **formHelpers**:通用表单操作(如 `triggerPickerChange`),不依赖具体业务
17
+ - **scrollHelpers**:`scrollToSelector`,在操作前将目标元素滚入视口
18
+ - **testLogger**:日志转发、错误捕获
19
+
20
+ 原则:通用逻辑抽取到 utils,保持测试用例主流程简洁可读。
21
+
22
+ ---
23
+
24
+ ## 2. 文档化测试流程
25
+
26
+ 在测试文件顶部用注释说明:测试目标、步骤顺序、每步在做什么、前置条件(如 Mock)、环境限制和注意事项。这样阅读测试文件即可理解整体流程,方便后续维护和扩展。
27
+
28
+ ---
29
+
30
+ ## 3. 生命周期与 Mock 策略
31
+
32
+ 在 `beforeAll` 中完成:启动小程序、安装日志转发、等待初始化,并按需 Mock(如 `requestSubscribeMessage`、`showModal`)。Mock 时只 Mock 会阻塞或依赖真实环境的调用,既保证测试可运行,又尽量保持真实行为。
33
+
34
+ ---
35
+
36
+ ## 4. 异步与等待策略
37
+
38
+ 避免硬编码 `wait(ms)`,优先使用 `waitFor`、`waitForElement`、`waitForPagePath` 等条件等待,直到条件满足或超时。这样既减少无效等待、缩短运行时间,又降低因时机不稳定导致的间歇性失败。
39
+
40
+ ---
41
+
42
+ ## 5. 数据与状态传递
43
+
44
+ 长流程测试在用例内用共享变量(如 `competitionId`、`competitionName`)串联各步骤。测试数据命名加时间戳(如 `E2E测试_${dateStr}`),避免多次运行相互干扰,确保每个用例有可追踪的唯一标识。
45
+
46
+ ---
47
+
48
+ ## 6. 清理测试数据
49
+
50
+ 在测试结尾(或 `afterAll`)删除本次创建的测试数据(如通过云函数删除比赛),避免数据库中残留脏数据,保证后续运行环境干净、可复现。
51
+
52
+ ---
53
+
54
+ ## 7. 元素查找与交互策略
55
+
56
+ 优先用语义化方式查找元素(如通过按钮文案 `btn.text()`),而非依赖易变的 DOM 结构或选择器。在点击前用 `scrollToSelector` 将目标元素滚入视口,避免被遮挡或不可见;对 picker、日期等复杂组件,可考虑用 `page.callMethod` 模拟事件,绕过 UI 模拟的限制。
57
+
58
+ ---
59
+
60
+ ## 8. 错误检测与验证
61
+
62
+ 通过 `installTestLogger` 将小程序内的 `console.error` 转发到测试端,用 `startErrorCapture` 和 `checkHadError` 在用例结束时检查是否发生过 JS 错误。这样不仅验证 UI 行为,也确保执行过程中没有未捕获异常或错误日志。
63
+
64
+ ---
65
+
66
+ ## 9. 单用例长流程设计
67
+
68
+ 将完整业务链路(新建→配置→发布→报名→审核→查看→取消→清理)放在同一个 `test()` 中,共享生命周期和状态,减少重复启动和 Mock。这样提高效率,但单步失败时难以精确定位,需在日志和断言上适当补充。
69
+
70
+ ---
71
+
72
+ ## 10. 小程序侧错误日志输出
73
+
74
+ **目的**:业务逻辑出错时能在自动化测试环境中可见、可追踪。
75
+
76
+ **实现要点**:
77
+
78
+ 1. **使用 `console.error` 输出错误**
79
+ 在 catch、异常分支、校验失败等处使用 `console.error`,例如:
80
+ ```javascript
81
+ try {
82
+ await someAsyncOp();
83
+ } catch (e) {
84
+ console.error('操作失败', e.message, e);
85
+ }
86
+ ```
87
+
88
+ 2. **提供有意义的错误信息**
89
+ 日志中包含错误类型、简要上下文、必要参数(如 ID、接口名),便于定位问题。
90
+
91
+ 3. **保持 testLogger 能力**
92
+ `testLogger` 会拦截并转发 `console.error` 到测试端,并标记 `hadError`,测试端可据此检测到业务错误。
93
+
94
+ 4. **避免吞掉错误**
95
+ 不要只 `console.error` 而不抛出或标记失败,否则日志能看到,但自动化测试无法断言结果。
96
+
97
+ ---
98
+
99
+ ## 11. 自动化测试通过错误日志检测运行结果
100
+
101
+ **目的**:不仅验证 UI 与业务行为,还确保运行期间无未捕获错误或 `console.error`,避免“表面通过、内藏错误”。
102
+
103
+ **实现要点**:
104
+
105
+ 1. **安装 testLogger**
106
+ 在 `beforeAll` 中启动小程序后调用:
107
+ ```javascript
108
+ await installTestLogger(miniProgram);
109
+ ```
110
+
111
+ 2. **测试前重置错误状态**
112
+ 在用例开始时调用:
113
+ ```javascript
114
+ startErrorCapture();
115
+ ```
116
+
117
+ 3. **测试结束时校验**
118
+ 用例末尾断言:
119
+ ```javascript
120
+ expect(checkHadError()).toBe(false);
121
+ ```
122
+
123
+ 4. **机制说明**
124
+ - `installTestLogger`:在小程序内 patch `console`,通过 `exposeFunction` 将日志转发到测试端,并监听未捕获异常。
125
+ - `startErrorCapture`:将 `hadError` 置为 false,开始新一轮检测。
126
+ - `checkHadError`:返回本次测试期间是否出现过 `console.error` 或未捕获异常。
127
+
128
+ 5. **长流程用例**
129
+ 单用例覆盖多步骤时,在流程开始前调用 `startErrorCapture()`,流程结束后调用 `checkHadError()` 一次,即可检测整个链路中是否出现错误日志或异常。
130
+
131
+ ---
132
+
133
+ ## 12. 业务流程中的页面跳转
134
+
135
+ 在业务流程测试中,页面间跳转时应**尽量使用页面操作的方式**(如点击按钮、链接、列表项等触发导航),而非在测试代码中直接调用 `page.navigateTo`、`switchTab` 等 API 跳转。
136
+
137
+ **理由**:通过页面操作模拟真实用户行为,既能验证导航入口是否正确(如按钮是否可点、跳转逻辑是否生效),也能覆盖从点击到跳转的完整链路;直接跳转则绕过了入口和中间逻辑,测试覆盖面不足,且难以发现入口相关的回归问题。
138
+
139
+ **例外**:在必须从特定页面开始、或入口不可用(如 Mock 场景)时,可使用 `navigationHelpers` 直接跳转作为前置步骤。
140
+
141
+ ---
142
+
143
+ ## 13. UI 失败不兜底云函数
144
+
145
+ **目的**:E2E 测试以验证真实用户路径为主;若通过 UI 操作失败,应暴露问题、驱动修正,而不是用云函数等“捷径”掩盖失败。
146
+
147
+ **原则**:
148
+
149
+ 1. **不兜底**
150
+ 当某步通过 UI 操作(点击、输入、导航等)失败时,**不要**在测试中再尝试通过云函数、`callMethod`、`evaluate` 等方式完成同一操作作为“后备”。例如:UI 删除活动失败时,不要再用云函数 `deleteActivity` 删除以“保证测试通过”。
151
+
152
+ 2. **检测并失败**
153
+ 一旦检测到 UI 操作未达到预期(如未找到按钮、操作后状态未更新、删除未成功等),应通过 `expect(...)` 让测试失败,并辅以清晰的日志(如 `debugLog('✗ UI删除失败,不调用云函数,测试应失败以驱动修正')`)。
154
+
155
+ 3. **驱动修正**
156
+ 测试失败会促使修正 UI 流程、Mock 策略或测试步骤本身,从而提升真实用户路径的可信度;若用云函数兜底,测试可能通过但 UI 问题被隐藏。
157
+
158
+ **适用场景**:所有依赖 UI 完成的关键步骤(如创建、编辑、删除、报名、取消等),其失败处理都应遵循本原则。
159
+
160
+ ---
161
+
162
+ ## 14. 测试用例实现方式单一清晰
163
+
164
+ **目的**:用例逻辑简单可读、行为稳定,避免“多种尝试、多种兜底”带来的复杂度和潜在误判。
165
+
166
+ **原则**:
167
+
168
+ 1. **编写与调试时可尝试多种实现**
169
+ 在编写或调试测试时,可以尝试多种查找方式、多种等待策略等,以验证哪种方式在环境中可靠。
170
+
171
+ 2. **最终只保留一种最清晰可靠的实现**
172
+ 定稿时只保留一种最清晰、最可靠的实现方式写进测试用例,删除其余尝试和兜底逻辑。例如:若页面已为删除按钮设置 `data-id`,则只用「文本为“删除”且 `data-id === activityId`」定位按钮,不再叠加“按标题+索引匹配”“找不到就用第一个”等后备方案。
173
+
174
+ 3. **避免多重 fallback 带来的问题**
175
+ 多重 fallback 会令用例难以理解和维护,且可能掩盖真实问题(如“用第一个删除按钮”可能误删其他数据)。找不到目标时,应让断言失败并辅以清晰日志,便于定位并修正页面或测试本身。
176
+
177
+ **适用场景**:元素查找、等待条件、状态判定等一切在测试中可能写出多种实现方式的逻辑。
178
+
179
+ ---
@@ -0,0 +1,97 @@
1
+ # 自动化测试日志原则
2
+
3
+ 为自动化测试设计一套使用 `console.debug`、`console.error` 输出日志的机制。何时该输出日志,输出什么级别的日志。
4
+
5
+ ---
6
+
7
+ ## 1. 背景
8
+
9
+ - **testLogger** 会将小程序内的 `console.log/info/warn/error/debug` 转发到测试终端
10
+ - **只有 `console.error` 会触发 `hadError`**,导致 `checkHadError()` 时测试失败
11
+ - 因此:**日志级别直接影响自动化测试的通过/失败判定**
12
+
13
+ ---
14
+
15
+ ## 2. 日志级别定义
16
+
17
+ | 级别 | 是否影响测试结果 | 使用场景 |
18
+ |------|------------------|----------|
19
+ | **console.error** | ✅ 会(视为失败) | 真实业务错误、异常、校验失败、不可恢复状态 |
20
+ | **console.debug** | ❌ 不会 | 流程关键节点、状态变化、诊断信息,便于排查问题 |
21
+
22
+ ---
23
+
24
+ ## 3. console.error —— 何时输出
25
+
26
+ 仅在 **业务错误、异常、校验失败、不可恢复状态** 时使用,且应包含足够上下文。
27
+
28
+ | 场景 | 示例 |
29
+ |------|------|
30
+ | catch 异常 | `try/catch` 中的异常、网络错误 |
31
+ | 云函数/接口返回错误 | `ret.errorcode !== 0` 或接口失败 |
32
+ | 数据校验失败 | 必填项为空、ID 无效、状态不合法 |
33
+ | 关键前置条件缺失 | 如活动/比赛 ID 不存在 |
34
+
35
+ **推荐格式:**
36
+ ```javascript
37
+ console.error('[模块名] 简短描述', { errorcode, msg, id, ... });
38
+ // 或
39
+ console.error('操作失败:', error.message, error);
40
+ ```
41
+
42
+ ---
43
+
44
+ ## 4. console.debug —— 何时输出
45
+
46
+ 用于 **可追踪流程和诊断** 的信息,不影响测试通过/失败。
47
+
48
+ | 场景 | 示例 |
49
+ |------|------|
50
+ | 关键流程节点 | 进入/离开页面、发起请求、收到响应 |
51
+ | 重要状态变化 | 报名成功、签到成功、状态切换 |
52
+ | 调试相关数据 | 请求参数、返回 ID、加载的数据量 |
53
+ | 替代无意义的 console.log | 点击事件、选择事件等开发调试用日志 |
54
+
55
+ **推荐格式:**
56
+ ```javascript
57
+ console.debug('[模块名] 操作/状态描述', { key: value });
58
+ ```
59
+
60
+ ---
61
+
62
+ ## 5. 不建议使用或需谨慎
63
+
64
+ | 级别 | 建议 |
65
+ |------|------|
66
+ | **console.log** | 可改为 `console.debug`,避免与正式环境日志混淆,语义更清晰 |
67
+ | **console.info** | 与 debug 类似,可统一用 `console.debug` |
68
+ | **console.warn** | 可用于降级、非致命问题;当前不触发 hadError,若需视为失败应改用 `error` |
69
+
70
+ ---
71
+
72
+ ## 6. 分层建议
73
+
74
+ | 代码位置 | 建议 |
75
+ |----------|------|
76
+ | **页面 / 业务逻辑** | catch、校验失败、接口错误 → `console.error`;关键流程节点 → `console.debug` |
77
+ | **clicom / 云调用** | 请求失败、云函数返回错误 → `console.error`;成功或调试信息 → `console.debug` |
78
+ | **云函数** | 业务错误 → `console.error`;调试/流程信息 → `console.debug` 或 `console.log`(云函数侧无 testLogger) |
79
+
80
+ ---
81
+
82
+ ## 7. 与自动化测试的配合
83
+
84
+ 1. **testLogger** 已转发所有 console 级别,测试端可看到完整输出
85
+ 2. 只有 **`console.error`** 会触发 `checkHadError()`
86
+ 3. **`console.debug`** 仅作诊断,不影响测试结果
87
+ 4. 测试中通过 `startErrorCapture()` + `checkHadError()` 可确保流程中无业务错误
88
+
89
+ ---
90
+
91
+ ## 8. 总结
92
+
93
+ - **`console.error`**:业务/技术错误,需要测试失败 → 仅在真实错误时使用
94
+ - **`console.debug`**:流程与诊断信息,帮助排查问题 → 用于关键步骤和状态变化
95
+ - 使用 `[模块名]` 前缀,便于搜索和定位
96
+ - 错误日志需包含 `errorcode`、`id`、`msg` 等上下文
97
+ - 避免只 `console.error` 而不抛出或标记失败,否则日志可见但自动化测试无法断言结果
package/index.js ADDED
@@ -0,0 +1,36 @@
1
+ /**
2
+ * laozhu_mp_e2e_testkit 统一导出
3
+ * 微信小程序 E2E:统一配置、日志、测试规范文档、工具助手
4
+ */
5
+
6
+ const config = require('./src/config/automatorConfig');
7
+ const logger = require('./src/logger/testLogger');
8
+ const debugConfig = require('./src/logger/debugConfig');
9
+ const debugHelpers = require('./src/logger/debugHelpers');
10
+ const waitHelpers = require('./src/utils/waitHelpers');
11
+ const pageHelpers = require('./src/utils/pageHelpers');
12
+ const navigationHelpers = require('./src/utils/navigationHelpers');
13
+ const formHelpers = require('./src/utils/formHelpers');
14
+ const scrollHelpers = require('./src/utils/scrollHelpers');
15
+
16
+ module.exports = {
17
+ // config
18
+ getAutomatorConfig: config.getAutomatorConfig,
19
+ checkCliExists: config.checkCliExists,
20
+ DEFAULT_CONFIG: config.DEFAULT_CONFIG,
21
+
22
+ // logger
23
+ installTestLogger: logger.installTestLogger,
24
+ startErrorCapture: logger.startErrorCapture,
25
+ checkHadError: logger.checkHadError,
26
+ debugConfig,
27
+ debugLog: debugHelpers.debugLog,
28
+ debugWait: debugHelpers.debugWait,
29
+
30
+ // utils
31
+ ...waitHelpers,
32
+ ...pageHelpers,
33
+ ...navigationHelpers,
34
+ ...formHelpers,
35
+ ...scrollHelpers,
36
+ };
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@witon/laozhu_mp_e2e_testkit",
3
+ "version": "1.0.0",
4
+ "description": "微信小程序 E2E 自动化测试:日志转发、测试规范文档、工具助手与 automator 统一配置",
5
+ "main": "index.js",
6
+ "files": [
7
+ "index.js",
8
+ "src/",
9
+ "docs/"
10
+ ],
11
+ "scripts": {
12
+ "prepublishOnly": "node -e \"require('fs').accessSync('index.js')\"",
13
+ "build": "echo No build step"
14
+ },
15
+ "peerDependencies": {
16
+ "miniprogram-automator": ">=0.12.0"
17
+ },
18
+ "keywords": [
19
+ "miniprogram",
20
+ "wechat",
21
+ "e2e",
22
+ "automator",
23
+ "jest"
24
+ ],
25
+ "license": "MIT",
26
+ "publishConfig": {
27
+ "access": "public"
28
+ }
29
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * 小程序自动化工具统一配置
3
+ * projectPath、cliPath 可通过环境变量或 getAutomatorConfig(overrides) 传入
4
+ */
5
+
6
+ // 默认配置:projectPath 优先从环境变量读取,否则需调用方传入 overrides
7
+ const DEFAULT_CONFIG = {
8
+ cliPath: process.env.WECHAT_CLI_PATH || "C:\\Program Files (x86)\\Tencent\\微信web开发者工具\\cli.bat",
9
+ projectPath: process.env.MINIPROGRAM_PROJECT_PATH || process.env.E2E_PROJECT_PATH || undefined,
10
+ launchTimeout: 300000,
11
+ testTimeout: 300000,
12
+ };
13
+
14
+ /**
15
+ * 获取自动化工具配置
16
+ * @param {Object} overrides - 覆盖默认配置,如 { projectPath: path.resolve(__dirname, '../..'), cliPath: '...' }
17
+ * @returns {Object} 配置对象
18
+ */
19
+ function getAutomatorConfig(overrides = {}) {
20
+ const merged = {
21
+ ...DEFAULT_CONFIG,
22
+ ...overrides,
23
+ };
24
+ if (merged.projectPath == null || merged.projectPath === '') {
25
+ console.warn('laozhu_mp_e2e_testkit: projectPath 未设置,请通过 overrides 或环境变量 MINIPROGRAM_PROJECT_PATH / E2E_PROJECT_PATH 指定小程序项目根路径');
26
+ }
27
+ return merged;
28
+ }
29
+
30
+ /**
31
+ * 检测微信开发者工具 CLI 是否存在
32
+ * @param {Object} [config] - 可选,若不传则使用 getAutomatorConfig()
33
+ * @returns {Promise<boolean>}
34
+ */
35
+ async function checkCliExists(config) {
36
+ const fs = require('fs').promises;
37
+ const cfg = config || getAutomatorConfig();
38
+ try {
39
+ await fs.access(cfg.cliPath);
40
+ return true;
41
+ } catch (error) {
42
+ console.warn(`警告: 未找到微信开发者工具 CLI: ${cfg.cliPath}`);
43
+ console.warn('请设置环境变量 WECHAT_CLI_PATH 或在调用 getAutomatorConfig 时传入 cliPath');
44
+ return false;
45
+ }
46
+ }
47
+
48
+ module.exports = {
49
+ getAutomatorConfig,
50
+ checkCliExists,
51
+ DEFAULT_CONFIG,
52
+ };
@@ -0,0 +1,12 @@
1
+ /**
2
+ * 调试模式配置
3
+ * 通过环境变量 TEST_DEBUG=true 启用
4
+ */
5
+
6
+ const DEBUG_CONFIG = {
7
+ ENABLED: process.env.TEST_DEBUG === 'true' || false,
8
+ DELAY_MS: 2000,
9
+ VERBOSE: true,
10
+ };
11
+
12
+ module.exports = DEBUG_CONFIG;
@@ -0,0 +1,24 @@
1
+ /**
2
+ * 调试工具函数
3
+ */
4
+
5
+ const DEBUG_CONFIG = require('./debugConfig');
6
+ const { wait } = require('../utils/waitHelpers');
7
+
8
+ function debugLog(...args) {
9
+ if (DEBUG_CONFIG.ENABLED && DEBUG_CONFIG.VERBOSE) {
10
+ console.log(...args);
11
+ }
12
+ }
13
+
14
+ async function debugWait(ms = null) {
15
+ if (DEBUG_CONFIG.ENABLED) {
16
+ const delayTime = ms || DEBUG_CONFIG.DELAY_MS;
17
+ await wait(delayTime);
18
+ }
19
+ }
20
+
21
+ module.exports = {
22
+ debugLog,
23
+ debugWait,
24
+ };
@@ -0,0 +1,106 @@
1
+ /**
2
+ * 小程序自动化测试 - 日志转发
3
+ * 使用 miniProgram.exposeFunction 将小程序内 console 输出转发到测试终端
4
+ */
5
+
6
+ const TEST_LOG_NAME = '__testLog__';
7
+
8
+ const errorCaptureState = {
9
+ hadError: false,
10
+ };
11
+
12
+ function bindingLog(level, location, ...args) {
13
+ if (level === 'error') {
14
+ if (location && typeof location === 'string') {
15
+ console.error('[MiniProgram][error]', location);
16
+ if (args.length > 0) {
17
+ console.error(...args);
18
+ }
19
+ } else {
20
+ console.error('[MiniProgram][error]', ...args);
21
+ }
22
+ errorCaptureState.hadError = true;
23
+ } else {
24
+ if (location !== undefined) {
25
+ console.log('[MiniProgram][' + level + ']', location, ...args);
26
+ } else {
27
+ console.log('[MiniProgram][' + level + ']', ...args);
28
+ }
29
+ }
30
+ }
31
+
32
+ function patchConsoleInMiniprogram() {
33
+ if (typeof __testLog__ !== 'function') return;
34
+ var methods = ['log', 'info', 'warn', 'error', 'debug'];
35
+ for (var i = 0; i < methods.length; i++) {
36
+ (function (level) {
37
+ var orig = console[level];
38
+ if (!orig) return;
39
+ console[level] = function () {
40
+ var args = Array.prototype.slice.call(arguments);
41
+ orig.apply(console, args);
42
+ if (level === 'error') {
43
+ try {
44
+ var stack = new Error().stack;
45
+ if (stack) {
46
+ var lines = stack.split('\n');
47
+ var location = null;
48
+ for (var j = 0; j < lines.length; j++) {
49
+ var line = lines[j];
50
+ if (line.indexOf('Error') === -1 &&
51
+ line.indexOf('console') === -1 &&
52
+ line.indexOf('__testLog__') === -1 &&
53
+ line.trim() !== '') {
54
+ location = line.trim();
55
+ break;
56
+ }
57
+ }
58
+ if (location) {
59
+ __testLog__.apply(null, [level, location].concat(args));
60
+ } else {
61
+ __testLog__.apply(null, [level, undefined].concat(args));
62
+ }
63
+ } else {
64
+ __testLog__.apply(null, [level, undefined].concat(args));
65
+ }
66
+ } catch (e) {
67
+ __testLog__.apply(null, [level, undefined].concat(args));
68
+ }
69
+ } else {
70
+ __testLog__.apply(null, [level, undefined].concat(args));
71
+ }
72
+ };
73
+ })(methods[i]);
74
+ }
75
+ }
76
+
77
+ async function installTestLogger(miniProgram) {
78
+ if (!miniProgram || typeof miniProgram.exposeFunction !== 'function') {
79
+ return;
80
+ }
81
+ try {
82
+ await miniProgram.exposeFunction(TEST_LOG_NAME, bindingLog);
83
+ await miniProgram.evaluate(patchConsoleInMiniprogram);
84
+ if (typeof miniProgram.on === 'function') {
85
+ miniProgram.on('exception', () => {
86
+ errorCaptureState.hadError = true;
87
+ });
88
+ }
89
+ } catch (err) {
90
+ console.warn('[testLogger] 安装小程序日志转发失败(不影响测试):', err.message);
91
+ }
92
+ }
93
+
94
+ function startErrorCapture() {
95
+ errorCaptureState.hadError = false;
96
+ }
97
+
98
+ function checkHadError() {
99
+ return errorCaptureState.hadError;
100
+ }
101
+
102
+ module.exports = {
103
+ installTestLogger,
104
+ startErrorCapture,
105
+ checkHadError,
106
+ };
@@ -0,0 +1,20 @@
1
+ /**
2
+ * 表单操作通用工具
3
+ */
4
+
5
+ const { wait } = require('./waitHelpers');
6
+
7
+ async function triggerPickerChange(page, methodName, value) {
8
+ try {
9
+ await page.callMethod(methodName, { detail: { value } });
10
+ await wait(200);
11
+ return true;
12
+ } catch (error) {
13
+ console.warn(`triggerPickerChange ${methodName} 失败:`, error.message);
14
+ return false;
15
+ }
16
+ }
17
+
18
+ module.exports = {
19
+ triggerPickerChange,
20
+ };
@@ -0,0 +1,50 @@
1
+ /**
2
+ * 导航辅助函数(不包含 TAB_BAR_PATHS,由项目在本地定义)
3
+ */
4
+
5
+ const { debugLog, debugWait } = require('../logger/debugHelpers');
6
+ const { wait } = require('./waitHelpers');
7
+
8
+ async function navigateTo(miniProgram, path, query = {}, options = {}) {
9
+ const { description = `导航到: ${path}` } = options;
10
+ debugLog(` 📌 ${description}...`);
11
+ await debugWait();
12
+
13
+ const queryString = Object.keys(query)
14
+ .map(key => `${key}=${encodeURIComponent(query[key])}`)
15
+ .join('&');
16
+ const url = queryString ? `${path}?${queryString}` : path;
17
+ const page = await miniProgram.navigateTo(url);
18
+
19
+ debugLog(` ✓ ${description}完成`);
20
+ await debugWait();
21
+ return page;
22
+ }
23
+
24
+ async function switchTab(miniProgram, tabBarPath, options = {}) {
25
+ const { description = `切换到TabBar: ${tabBarPath}` } = options;
26
+ debugLog(` 📌 ${description}...`);
27
+ await debugWait();
28
+
29
+ const page = await miniProgram.switchTab(tabBarPath);
30
+ debugLog(` ✓ ${description}完成`);
31
+ await debugWait();
32
+ return page;
33
+ }
34
+
35
+ async function navigateBack(page, options = {}) {
36
+ const { description = '返回上一页' } = options;
37
+ debugLog(` 📌 ${description}...`);
38
+ await debugWait();
39
+
40
+ await page.navigateBack();
41
+ await wait(1000);
42
+ debugLog(` ✓ ${description}完成`);
43
+ await debugWait();
44
+ }
45
+
46
+ module.exports = {
47
+ navigateTo,
48
+ switchTab,
49
+ navigateBack,
50
+ };
@@ -0,0 +1,174 @@
1
+ /**
2
+ * 页面操作辅助函数
3
+ */
4
+
5
+ const { waitForElement, wait } = require('./waitHelpers');
6
+ const { debugLog, debugWait } = require('../logger/debugHelpers');
7
+
8
+ async function findElement(pageOrElement, selector, timeout = 5000) {
9
+ return await waitForElement(pageOrElement, selector, timeout);
10
+ }
11
+
12
+ async function findAllButtonsByText(pageOrElement, pattern, options = {}) {
13
+ const { timeout = 5000 } = options;
14
+ const startTime = Date.now();
15
+ let isRegExp = pattern instanceof RegExp;
16
+ let matcher = isRegExp ? (txt) => pattern.test(txt) : (txt) => txt && txt.includes(pattern);
17
+
18
+ while (Date.now() - startTime < timeout) {
19
+ try {
20
+ const buttons = await pageOrElement.$$('button').catch(() => []);
21
+ const matched = [];
22
+ for (const btn of buttons) {
23
+ try {
24
+ const btnText = await btn.text();
25
+ if (btnText && matcher(btnText.trim())) matched.push(btn);
26
+ } catch (e) { /* ignore */ }
27
+ }
28
+ if (matched.length > 0) return matched;
29
+ } catch (e) { /* ignore */ }
30
+ await wait(100);
31
+ }
32
+ return [];
33
+ }
34
+
35
+ async function findButtonByText(pageOrElement, pattern, options = {}) {
36
+ const buttons = await findAllButtonsByText(pageOrElement, pattern, options);
37
+ if (buttons && buttons.length > 0) return buttons[0];
38
+ return null;
39
+ }
40
+
41
+ async function findElements(pageOrElement, selector, timeout = 5000) {
42
+ const startTime = Date.now();
43
+ while (Date.now() - startTime < timeout) {
44
+ try {
45
+ const elements = await pageOrElement.$$(selector);
46
+ if (elements && elements.length > 0) return elements;
47
+ } catch (error) { /* continue */ }
48
+ await wait(100);
49
+ }
50
+ return [];
51
+ }
52
+
53
+ async function clickElement(pageOrElement, selector, options = {}) {
54
+ const { description = `点击元素: ${selector}`, timeout = 5000 } = options;
55
+ debugLog(` 📌 ${description}...`);
56
+ await debugWait();
57
+
58
+ const element = await findElement(pageOrElement, selector, timeout);
59
+ await element.tap();
60
+ await wait(300);
61
+
62
+ debugLog(` ✓ ${description}完成`);
63
+ await debugWait();
64
+ return element;
65
+ }
66
+
67
+ async function elementExists(pageOrElement, selector) {
68
+ try {
69
+ const element = await pageOrElement.$(selector);
70
+ return !!element;
71
+ } catch (error) {
72
+ return false;
73
+ }
74
+ }
75
+
76
+ async function getElementText(pageOrElement, selector, options = {}) {
77
+ const { description = `获取元素文本: ${selector}`, timeout = 5000 } = options;
78
+ debugLog(` 📌 ${description}...`);
79
+ await debugWait();
80
+
81
+ const element = await findElement(pageOrElement, selector, timeout);
82
+ const text = await element.text();
83
+
84
+ debugLog(` ✓ ${description}完成: ${text.substring(0, 50)}...`);
85
+ await debugWait();
86
+ return text;
87
+ }
88
+
89
+ async function inputText(pageOrElement, selector, text, options = {}) {
90
+ const { description = `输入文本到: ${selector}`, timeout = 5000 } = options;
91
+ debugLog(` 📌 ${description}...`);
92
+ await debugWait();
93
+
94
+ const element = await findElement(pageOrElement, selector, timeout);
95
+ await element.input(text);
96
+ await wait(200);
97
+
98
+ debugLog(` ✓ ${description}完成: ${text}`);
99
+ await debugWait();
100
+ return element;
101
+ }
102
+
103
+ async function scrollPage(page, x, y, options = {}) {
104
+ const { description = `滑动页面到 scrollTop: ${y}` } = options;
105
+ debugLog(` 📌 ${description}...`);
106
+ await debugWait();
107
+ debugLog(` ⚠ 页面滚动功能:miniprogram-automator 可能不支持直接滚动`);
108
+ await wait(300);
109
+ debugLog(` ✓ ${description}完成(已跳过实际滚动操作)`);
110
+ await debugWait();
111
+ }
112
+
113
+ async function pullDownRefresh(page, options = {}) {
114
+ const { description = '下拉刷新' } = options;
115
+ debugLog(` 📌 ${description}...`);
116
+ await debugWait();
117
+ debugLog(` ⚠ 下拉刷新功能:miniprogram-automator 可能不支持直接触发`);
118
+ await wait(500);
119
+ debugLog(` ✓ ${description}完成(已跳过实际刷新操作)`);
120
+ await debugWait();
121
+ }
122
+
123
+ async function clickComponentAndNavigate(page, selector, options = {}) {
124
+ const { description = `点击组件: ${selector}`, timeout = 5000, waitAfterClick = 2000 } = options;
125
+ debugLog(` 📌 ${description}...`);
126
+ await debugWait();
127
+
128
+ const element = await findElement(page, selector, timeout);
129
+ await element.tap();
130
+ debugLog(` ✓ 已点击,等待跳转...`);
131
+ await wait(waitAfterClick);
132
+ await debugWait();
133
+ return element;
134
+ }
135
+
136
+ async function clickElementObject(element, options = {}) {
137
+ const { description = '点击元素', waitAfterClick = 2000 } = options;
138
+ debugLog(` 📌 ${description}...`);
139
+ await debugWait();
140
+
141
+ await element.tap();
142
+ debugLog(` ✓ 已点击,等待响应...`);
143
+ await wait(waitAfterClick);
144
+ await debugWait();
145
+ return element;
146
+ }
147
+
148
+ async function selectOption(page, selector, optionValue, options = {}) {
149
+ const { description = `选择选项: ${optionValue}` } = options;
150
+ debugLog(` 📌 ${description}...`);
151
+ await debugWait();
152
+
153
+ const pickerElement = await findElement(page, selector);
154
+ await pickerElement.tap();
155
+ await wait(500);
156
+ debugLog(` ✓ ${description}完成`);
157
+ await debugWait();
158
+ }
159
+
160
+ module.exports = {
161
+ findElement,
162
+ findElements,
163
+ clickElement,
164
+ elementExists,
165
+ getElementText,
166
+ inputText,
167
+ scrollPage,
168
+ pullDownRefresh,
169
+ clickComponentAndNavigate,
170
+ clickElementObject,
171
+ selectOption,
172
+ findButtonByText,
173
+ findAllButtonsByText,
174
+ };
@@ -0,0 +1,61 @@
1
+ /**
2
+ * 测试用滚动辅助
3
+ */
4
+
5
+ const { debugLog } = require('../logger/debugHelpers');
6
+ const { wait } = require('./waitHelpers');
7
+
8
+ async function scrollToSelector(miniProgram, selector, options = {}) {
9
+ const { offsetTop: offsetTopOption = 80 } = options;
10
+ debugLog(` 📜 滚动到组件: ${selector}`);
11
+
12
+ try {
13
+ const page = await miniProgram.currentPage();
14
+ if (!page) return;
15
+
16
+ const element = await page.$(selector);
17
+ if (!element) {
18
+ debugLog(` ⚠ 未找到元素: ${selector}`);
19
+ return;
20
+ }
21
+
22
+ const offset = await element.offset();
23
+ if (!offset || offset.top === undefined) {
24
+ debugLog(` ⚠ 无法获取元素位置: ${selector}`);
25
+ return;
26
+ }
27
+
28
+ const scrollTop = Math.max(0, offset.top - offsetTopOption);
29
+ await miniProgram.pageScrollTo(scrollTop);
30
+ await wait(350);
31
+ } catch (e) {
32
+ debugLog(` ⚠ 滚动失败: ${e.message || e}`);
33
+ }
34
+ }
35
+
36
+ async function scrollToElement(miniProgram, element, options = {}) {
37
+ const { offsetTop: offsetTopOption = 80 } = options;
38
+
39
+ if (!element) {
40
+ debugLog(' ⚠ scrollToElement: element 为空');
41
+ return;
42
+ }
43
+
44
+ try {
45
+ const offset = await element.offset();
46
+ if (!offset || offset.top === undefined) {
47
+ debugLog(' ⚠ scrollToElement: 无法获取元素位置');
48
+ return;
49
+ }
50
+ const scrollTop = Math.max(0, offset.top - offsetTopOption);
51
+ await miniProgram.pageScrollTo(scrollTop);
52
+ await wait(350);
53
+ } catch (e) {
54
+ debugLog(` ⚠ scrollToElement 滚动失败: ${e.message || e}`);
55
+ }
56
+ }
57
+
58
+ module.exports = {
59
+ scrollToSelector,
60
+ scrollToElement,
61
+ };
@@ -0,0 +1,147 @@
1
+ /**
2
+ * 等待和断言辅助函数
3
+ */
4
+
5
+ function wait(ms) {
6
+ return new Promise(resolve => setTimeout(resolve, ms));
7
+ }
8
+
9
+ async function waitForTextNotInPage(page, pattern, timeout = 5000, interval = 200) {
10
+ const isRegExp = pattern instanceof RegExp;
11
+ const matcher = isRegExp
12
+ ? (txt) => pattern.test(txt)
13
+ : (txt) => txt && txt.includes(pattern);
14
+ const startTime = Date.now();
15
+ while (Date.now() - startTime < timeout) {
16
+ try {
17
+ const views = await page.$$('view').catch(() => []);
18
+ const texts = await Promise.all(views.map(v => v.text().catch(() => '')));
19
+ const combined = texts.join(' ');
20
+ if (!texts.some(txt => matcher(txt)) && !matcher(combined)) {
21
+ return true;
22
+ }
23
+ } catch (e) {
24
+ return true;
25
+ }
26
+ await wait(interval);
27
+ }
28
+ throw new Error(`页面 ${timeout}ms 内未移除指定文本: "${pattern}"`);
29
+ }
30
+
31
+ async function waitForTextInPage(page, pattern, timeout = 5000, interval = 200) {
32
+ const isRegExp = pattern instanceof RegExp;
33
+ const matcher = isRegExp
34
+ ? (txt) => pattern.test(txt)
35
+ : (txt) => txt && txt.includes(pattern);
36
+ const startTime = Date.now();
37
+ while (Date.now() - startTime < timeout) {
38
+ try {
39
+ const views = await page.$$('view').catch(() => []);
40
+ const texts = await Promise.all(views.map(v => v.text().catch(() => '')));
41
+ const combined = texts.join(' ');
42
+ if (texts.some(txt => matcher(txt)) || matcher(combined)) {
43
+ return true;
44
+ }
45
+ } catch (e) {
46
+ // ignore
47
+ }
48
+ await wait(interval);
49
+ }
50
+ throw new Error(`页面 ${timeout}ms 内未出现指定文本: "${pattern}"`);
51
+ }
52
+
53
+ async function waitFor(condition, timeout = 5000, interval = 100, errorMessage = '等待条件超时') {
54
+ const startTime = Date.now();
55
+ while (Date.now() - startTime < timeout) {
56
+ try {
57
+ const result = await condition();
58
+ if (result) return true;
59
+ } catch (error) {
60
+ // continue
61
+ }
62
+ await wait(interval);
63
+ }
64
+ throw new Error(`${errorMessage}: ${timeout}ms`);
65
+ }
66
+
67
+ async function waitForElement(page, selector, timeout = 5000) {
68
+ const startTime = Date.now();
69
+ while (Date.now() - startTime < timeout) {
70
+ try {
71
+ const element = await page.$(selector);
72
+ if (element) return element;
73
+ } catch (error) {
74
+ // continue
75
+ }
76
+ await wait(100);
77
+ }
78
+ throw new Error(`元素未找到: ${selector} (超时: ${timeout}ms)`);
79
+ }
80
+
81
+ async function waitForAnyElement(page, selectors, timeout = 8000) {
82
+ const list = Array.isArray(selectors) ? selectors : [selectors];
83
+ const startTime = Date.now();
84
+ while (Date.now() - startTime < timeout) {
85
+ for (const sel of list) {
86
+ try {
87
+ const el = await page.$(sel);
88
+ if (el) return el;
89
+ } catch (e) { /* ignore */ }
90
+ }
91
+ await wait(100);
92
+ }
93
+ throw new Error(`等待任一元素超时: [${list.join(', ')}] (${timeout}ms)`);
94
+ }
95
+
96
+ async function waitForPagePath(miniProgram, condition, options = {}) {
97
+ const { timeout = 15000, interval = 200 } = options;
98
+ const isString = typeof condition === 'string';
99
+ const predicate = isString ? (path) => (path || '').includes(condition) : condition;
100
+ const errorMsg = isString ? `等待页面路径包含 "${condition}" 超时` : '等待页面路径满足条件超时';
101
+
102
+ let lastPage = null;
103
+ let lastPath = '';
104
+ try {
105
+ await waitFor(
106
+ async () => {
107
+ lastPage = await miniProgram.currentPage();
108
+ lastPath = lastPage ? (lastPage.path || '') : '';
109
+ return predicate(lastPath);
110
+ },
111
+ timeout,
112
+ interval,
113
+ errorMsg
114
+ );
115
+ } catch (err) {
116
+ const pathInfo = lastPath !== undefined && lastPath !== '' ? ` 当前 path: "${lastPath}"` : ' (currentPage 或 path 为空)';
117
+ throw new Error(`${err.message}${pathInfo}`);
118
+ }
119
+ return lastPage;
120
+ }
121
+
122
+ async function waitForPageToLeave(miniProgram, pathToLeave, options = {}) {
123
+ const { timeout = 15000, interval = 200 } = options;
124
+ let lastPage = null;
125
+ await waitFor(
126
+ async () => {
127
+ lastPage = await miniProgram.currentPage();
128
+ const path = lastPage ? (lastPage.path || '') : '';
129
+ return !path.includes(pathToLeave);
130
+ },
131
+ timeout,
132
+ interval,
133
+ `等待离开页面 "${pathToLeave}" 超时`
134
+ );
135
+ return lastPage;
136
+ }
137
+
138
+ module.exports = {
139
+ wait,
140
+ waitFor,
141
+ waitForElement,
142
+ waitForAnyElement,
143
+ waitForPagePath,
144
+ waitForPageToLeave,
145
+ waitForTextInPage,
146
+ waitForTextNotInPage,
147
+ };