weixin-devtools-mcp 0.0.1
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 +375 -0
- package/build/index.js +542 -0
- package/build/server.js +200 -0
- package/build/tools/ToolDefinition.js +52 -0
- package/build/tools/assert.js +245 -0
- package/build/tools/connection.js +670 -0
- package/build/tools/console.js +192 -0
- package/build/tools/diagnose.js +348 -0
- package/build/tools/index.js +80 -0
- package/build/tools/input.js +255 -0
- package/build/tools/navigate.js +310 -0
- package/build/tools/network.js +1111 -0
- package/build/tools/page.js +150 -0
- package/build/tools/screenshot.js +47 -0
- package/build/tools/snapshot.js +45 -0
- package/build/tools.js +2131 -0
- package/package.json +58 -0
package/build/tools.js
ADDED
|
@@ -0,0 +1,2131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 微信开发者工具 MCP 工具函数
|
|
3
|
+
* 提供可测试的纯函数实现
|
|
4
|
+
*/
|
|
5
|
+
import automator from "miniprogram-automator";
|
|
6
|
+
import path from "path";
|
|
7
|
+
import fs from "fs";
|
|
8
|
+
import { spawn } from "child_process";
|
|
9
|
+
import { promisify } from "util";
|
|
10
|
+
const sleep = promisify(setTimeout);
|
|
11
|
+
/**
|
|
12
|
+
* 开发者工具连接错误类
|
|
13
|
+
*/
|
|
14
|
+
export class DevToolsConnectionError extends Error {
|
|
15
|
+
phase;
|
|
16
|
+
originalError;
|
|
17
|
+
details;
|
|
18
|
+
constructor(message, phase, originalError, details) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.phase = phase;
|
|
21
|
+
this.originalError = originalError;
|
|
22
|
+
this.details = details;
|
|
23
|
+
this.name = 'DevToolsConnectionError';
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* 连接到微信开发者工具
|
|
28
|
+
*
|
|
29
|
+
* @param options 连接选项
|
|
30
|
+
* @returns 连接结果
|
|
31
|
+
* @throws 连接失败时抛出错误
|
|
32
|
+
*/
|
|
33
|
+
export async function connectDevtools(options) {
|
|
34
|
+
const { projectPath, cliPath, port } = options;
|
|
35
|
+
if (!projectPath) {
|
|
36
|
+
throw new Error("项目路径是必需的");
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
// 处理@playground/wx格式的路径,转换为绝对文件系统路径
|
|
40
|
+
let resolvedProjectPath = projectPath;
|
|
41
|
+
if (projectPath.startsWith('@playground/')) {
|
|
42
|
+
// 转换为相对路径,然后解析为绝对路径
|
|
43
|
+
const relativePath = projectPath.replace('@playground/', 'playground/');
|
|
44
|
+
resolvedProjectPath = path.resolve(process.cwd(), relativePath);
|
|
45
|
+
}
|
|
46
|
+
else if (!path.isAbsolute(projectPath)) {
|
|
47
|
+
// 如果不是绝对路径,转换为绝对路径
|
|
48
|
+
resolvedProjectPath = path.resolve(process.cwd(), projectPath);
|
|
49
|
+
}
|
|
50
|
+
// 验证项目路径是否存在
|
|
51
|
+
if (!fs.existsSync(resolvedProjectPath)) {
|
|
52
|
+
throw new Error(`Project path '${resolvedProjectPath}' doesn't exist`);
|
|
53
|
+
}
|
|
54
|
+
// 构建 automator.launch 的选项
|
|
55
|
+
const launchOptions = { projectPath: resolvedProjectPath };
|
|
56
|
+
if (cliPath)
|
|
57
|
+
launchOptions.cliPath = cliPath;
|
|
58
|
+
if (port)
|
|
59
|
+
launchOptions.port = port;
|
|
60
|
+
// 启动并连接微信开发者工具
|
|
61
|
+
const miniProgram = await automator.launch(launchOptions);
|
|
62
|
+
// 获取当前页面
|
|
63
|
+
const currentPage = await miniProgram.currentPage();
|
|
64
|
+
if (!currentPage) {
|
|
65
|
+
throw new Error("无法获取当前页面");
|
|
66
|
+
}
|
|
67
|
+
const pagePath = await currentPage.path;
|
|
68
|
+
// 自动启动网络监听
|
|
69
|
+
try {
|
|
70
|
+
// 创建请求拦截器(直接内联函数)
|
|
71
|
+
await miniProgram.mockWxMethod('request', function (options) {
|
|
72
|
+
// @ts-ignore - wx is available in WeChat miniprogram environment
|
|
73
|
+
const wxObj = (typeof wx !== 'undefined' ? wx : null);
|
|
74
|
+
if (!wxObj)
|
|
75
|
+
return this.origin(options);
|
|
76
|
+
if (!wxObj.__networkLogs)
|
|
77
|
+
wxObj.__networkLogs = [];
|
|
78
|
+
const requestId = 'req_' + Date.now() + '_' + Math.random().toString(36).substring(2, 9);
|
|
79
|
+
const startTime = Date.now();
|
|
80
|
+
const originalSuccess = options.success;
|
|
81
|
+
options.success = function (res) {
|
|
82
|
+
wxObj.__networkLogs.push({
|
|
83
|
+
id: requestId,
|
|
84
|
+
type: 'request',
|
|
85
|
+
url: options.url,
|
|
86
|
+
method: options.method || 'GET',
|
|
87
|
+
headers: options.header,
|
|
88
|
+
data: options.data,
|
|
89
|
+
statusCode: res.statusCode,
|
|
90
|
+
response: res.data,
|
|
91
|
+
duration: Date.now() - startTime,
|
|
92
|
+
timestamp: new Date().toISOString(),
|
|
93
|
+
success: true
|
|
94
|
+
});
|
|
95
|
+
if (originalSuccess)
|
|
96
|
+
originalSuccess(res);
|
|
97
|
+
};
|
|
98
|
+
const originalFail = options.fail;
|
|
99
|
+
options.fail = function (err) {
|
|
100
|
+
wxObj.__networkLogs.push({
|
|
101
|
+
id: requestId,
|
|
102
|
+
type: 'request',
|
|
103
|
+
url: options.url,
|
|
104
|
+
method: options.method || 'GET',
|
|
105
|
+
headers: options.header,
|
|
106
|
+
data: options.data,
|
|
107
|
+
error: err.errMsg || String(err),
|
|
108
|
+
duration: Date.now() - startTime,
|
|
109
|
+
timestamp: new Date().toISOString(),
|
|
110
|
+
success: false
|
|
111
|
+
});
|
|
112
|
+
if (originalFail)
|
|
113
|
+
originalFail(err);
|
|
114
|
+
};
|
|
115
|
+
return this.origin(options);
|
|
116
|
+
});
|
|
117
|
+
// 拦截 uploadFile
|
|
118
|
+
await miniProgram.mockWxMethod('uploadFile', function (options) {
|
|
119
|
+
// @ts-ignore
|
|
120
|
+
const wxObj = (typeof wx !== 'undefined' ? wx : null);
|
|
121
|
+
if (!wxObj)
|
|
122
|
+
return this.origin(options);
|
|
123
|
+
if (!wxObj.__networkLogs)
|
|
124
|
+
wxObj.__networkLogs = [];
|
|
125
|
+
const requestId = 'req_' + Date.now() + '_' + Math.random().toString(36).substring(2, 9);
|
|
126
|
+
const startTime = Date.now();
|
|
127
|
+
const originalSuccess = options.success;
|
|
128
|
+
options.success = function (res) {
|
|
129
|
+
wxObj.__networkLogs.push({
|
|
130
|
+
id: requestId,
|
|
131
|
+
type: 'uploadFile',
|
|
132
|
+
url: options.url,
|
|
133
|
+
statusCode: res.statusCode,
|
|
134
|
+
duration: Date.now() - startTime,
|
|
135
|
+
timestamp: new Date().toISOString(),
|
|
136
|
+
success: true
|
|
137
|
+
});
|
|
138
|
+
if (originalSuccess)
|
|
139
|
+
originalSuccess(res);
|
|
140
|
+
};
|
|
141
|
+
const originalFail = options.fail;
|
|
142
|
+
options.fail = function (err) {
|
|
143
|
+
wxObj.__networkLogs.push({
|
|
144
|
+
id: requestId,
|
|
145
|
+
type: 'uploadFile',
|
|
146
|
+
url: options.url,
|
|
147
|
+
error: err.errMsg || String(err),
|
|
148
|
+
duration: Date.now() - startTime,
|
|
149
|
+
timestamp: new Date().toISOString(),
|
|
150
|
+
success: false
|
|
151
|
+
});
|
|
152
|
+
if (originalFail)
|
|
153
|
+
originalFail(err);
|
|
154
|
+
};
|
|
155
|
+
return this.origin(options);
|
|
156
|
+
});
|
|
157
|
+
// 拦截 downloadFile
|
|
158
|
+
await miniProgram.mockWxMethod('downloadFile', function (options) {
|
|
159
|
+
// @ts-ignore
|
|
160
|
+
const wxObj = (typeof wx !== 'undefined' ? wx : null);
|
|
161
|
+
if (!wxObj)
|
|
162
|
+
return this.origin(options);
|
|
163
|
+
if (!wxObj.__networkLogs)
|
|
164
|
+
wxObj.__networkLogs = [];
|
|
165
|
+
const requestId = 'req_' + Date.now() + '_' + Math.random().toString(36).substring(2, 9);
|
|
166
|
+
const startTime = Date.now();
|
|
167
|
+
const originalSuccess = options.success;
|
|
168
|
+
options.success = function (res) {
|
|
169
|
+
wxObj.__networkLogs.push({
|
|
170
|
+
id: requestId,
|
|
171
|
+
type: 'downloadFile',
|
|
172
|
+
url: options.url,
|
|
173
|
+
statusCode: res.statusCode,
|
|
174
|
+
duration: Date.now() - startTime,
|
|
175
|
+
timestamp: new Date().toISOString(),
|
|
176
|
+
success: true
|
|
177
|
+
});
|
|
178
|
+
if (originalSuccess)
|
|
179
|
+
originalSuccess(res);
|
|
180
|
+
};
|
|
181
|
+
const originalFail = options.fail;
|
|
182
|
+
options.fail = function (err) {
|
|
183
|
+
wxObj.__networkLogs.push({
|
|
184
|
+
id: requestId,
|
|
185
|
+
type: 'downloadFile',
|
|
186
|
+
url: options.url,
|
|
187
|
+
error: err.errMsg || String(err),
|
|
188
|
+
duration: Date.now() - startTime,
|
|
189
|
+
timestamp: new Date().toISOString(),
|
|
190
|
+
success: false
|
|
191
|
+
});
|
|
192
|
+
if (originalFail)
|
|
193
|
+
originalFail(err);
|
|
194
|
+
};
|
|
195
|
+
return this.origin(options);
|
|
196
|
+
});
|
|
197
|
+
// 拦截 Mpx 框架的 $xfetch(与 wx.request 同步注入,提高首批请求捕获率)
|
|
198
|
+
await miniProgram.evaluate(function () {
|
|
199
|
+
// @ts-ignore - wx is available in WeChat miniprogram environment
|
|
200
|
+
if (typeof wx === 'undefined')
|
|
201
|
+
return;
|
|
202
|
+
// @ts-ignore
|
|
203
|
+
wx.__networkLogs = wx.__networkLogs || [];
|
|
204
|
+
// 检测 Mpx 框架
|
|
205
|
+
// @ts-ignore - getApp is available in WeChat miniprogram environment
|
|
206
|
+
const app = typeof getApp !== 'undefined' ? getApp() : null;
|
|
207
|
+
const hasMpxFetch = app &&
|
|
208
|
+
app.$xfetch &&
|
|
209
|
+
app.$xfetch.interceptors &&
|
|
210
|
+
typeof app.$xfetch.interceptors.request.use === 'function';
|
|
211
|
+
// 调试日志
|
|
212
|
+
// @ts-ignore - 在运行时环境中输出调试信息
|
|
213
|
+
const debugInfo = {
|
|
214
|
+
// @ts-ignore
|
|
215
|
+
hasGetApp: typeof getApp !== 'undefined',
|
|
216
|
+
hasApp: !!app,
|
|
217
|
+
has$xfetch: !!(app && app.$xfetch),
|
|
218
|
+
hasInterceptors: !!(app && app.$xfetch && app.$xfetch.interceptors),
|
|
219
|
+
hasMpxFetch: hasMpxFetch
|
|
220
|
+
};
|
|
221
|
+
console.log('[MCP-DEBUG] Mpx检测:', debugInfo);
|
|
222
|
+
// 强制安装 Mpx 拦截器(不检查标志,每次都重新安装以覆盖旧的)
|
|
223
|
+
// 这样可以解决小程序未重新加载导致标志残留的问题
|
|
224
|
+
// @ts-ignore
|
|
225
|
+
if (hasMpxFetch) {
|
|
226
|
+
console.log('[MCP] 正在安装 Mpx $xfetch 拦截器(强制覆盖)...');
|
|
227
|
+
// 安装 Mpx 请求拦截器
|
|
228
|
+
// @ts-ignore
|
|
229
|
+
app.$xfetch.interceptors.request.use(function (config) {
|
|
230
|
+
const requestId = 'mpx_' + Date.now() + '_' + Math.random().toString(36).substring(2, 9);
|
|
231
|
+
const startTime = Date.now();
|
|
232
|
+
config.__mcp_requestId = requestId;
|
|
233
|
+
config.__mcp_startTime = startTime;
|
|
234
|
+
// @ts-ignore
|
|
235
|
+
wx.__networkLogs.push({
|
|
236
|
+
id: requestId,
|
|
237
|
+
type: 'request',
|
|
238
|
+
method: config.method || 'GET',
|
|
239
|
+
url: config.url,
|
|
240
|
+
headers: config.header || config.headers,
|
|
241
|
+
data: config.data,
|
|
242
|
+
params: config.params,
|
|
243
|
+
timestamp: new Date().toISOString(),
|
|
244
|
+
source: 'getApp().$xfetch',
|
|
245
|
+
phase: 'request'
|
|
246
|
+
});
|
|
247
|
+
return config;
|
|
248
|
+
});
|
|
249
|
+
// 安装 Mpx 响应拦截器
|
|
250
|
+
// @ts-ignore
|
|
251
|
+
app.$xfetch.interceptors.response.use(function onSuccess(response) {
|
|
252
|
+
const requestId = response.requestConfig?.__mcp_requestId;
|
|
253
|
+
const startTime = response.requestConfig?.__mcp_startTime || Date.now();
|
|
254
|
+
// @ts-ignore
|
|
255
|
+
wx.__networkLogs.push({
|
|
256
|
+
id: requestId,
|
|
257
|
+
type: 'response',
|
|
258
|
+
statusCode: response.status,
|
|
259
|
+
data: response.data,
|
|
260
|
+
headers: response.header || response.headers,
|
|
261
|
+
duration: Date.now() - startTime,
|
|
262
|
+
timestamp: new Date().toISOString(),
|
|
263
|
+
source: 'getApp().$xfetch',
|
|
264
|
+
phase: 'response',
|
|
265
|
+
success: true
|
|
266
|
+
});
|
|
267
|
+
return response;
|
|
268
|
+
}, function onError(error) {
|
|
269
|
+
const requestId = error.requestConfig?.__mcp_requestId;
|
|
270
|
+
const startTime = error.requestConfig?.__mcp_startTime || Date.now();
|
|
271
|
+
// @ts-ignore
|
|
272
|
+
wx.__networkLogs.push({
|
|
273
|
+
id: requestId,
|
|
274
|
+
type: 'response',
|
|
275
|
+
statusCode: error.status || error.statusCode,
|
|
276
|
+
error: error.message || error.errMsg || String(error),
|
|
277
|
+
duration: Date.now() - startTime,
|
|
278
|
+
timestamp: new Date().toISOString(),
|
|
279
|
+
source: 'getApp().$xfetch',
|
|
280
|
+
phase: 'response',
|
|
281
|
+
success: false
|
|
282
|
+
});
|
|
283
|
+
throw error;
|
|
284
|
+
});
|
|
285
|
+
console.log('[MCP] Mpx $xfetch 拦截器安装完成');
|
|
286
|
+
}
|
|
287
|
+
// @ts-ignore
|
|
288
|
+
wx.__networkInterceptorsInstalled = true;
|
|
289
|
+
});
|
|
290
|
+
console.log('[connectDevtools] 网络监听已自动启动(包含 Mpx 框架支持)');
|
|
291
|
+
}
|
|
292
|
+
catch (err) {
|
|
293
|
+
console.warn('[connectDevtools] 网络监听启动失败:', err);
|
|
294
|
+
}
|
|
295
|
+
return {
|
|
296
|
+
miniProgram,
|
|
297
|
+
currentPage,
|
|
298
|
+
pagePath
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
catch (error) {
|
|
302
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
303
|
+
throw new Error(`连接微信开发者工具失败: ${errorMessage}`);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* 智能连接到微信开发者工具(优化版)
|
|
308
|
+
* 支持多种连接模式和智能回退
|
|
309
|
+
*
|
|
310
|
+
* @param options 增强的连接选项
|
|
311
|
+
* @returns 详细连接结果
|
|
312
|
+
*/
|
|
313
|
+
export async function connectDevtoolsEnhanced(options) {
|
|
314
|
+
const { mode = 'auto', fallbackMode = true, healthCheck = true, verbose = false } = options;
|
|
315
|
+
const startTime = Date.now();
|
|
316
|
+
// 验证项目路径(在所有模式执行前统一验证)
|
|
317
|
+
if (!options.projectPath) {
|
|
318
|
+
throw new Error("项目路径是必需的");
|
|
319
|
+
}
|
|
320
|
+
// 解析并验证项目路径
|
|
321
|
+
let resolvedProjectPath = options.projectPath;
|
|
322
|
+
if (options.projectPath.startsWith('@playground/')) {
|
|
323
|
+
const relativePath = options.projectPath.replace('@playground/', 'playground/');
|
|
324
|
+
resolvedProjectPath = path.resolve(process.cwd(), relativePath);
|
|
325
|
+
}
|
|
326
|
+
else if (!path.isAbsolute(options.projectPath)) {
|
|
327
|
+
resolvedProjectPath = path.resolve(process.cwd(), options.projectPath);
|
|
328
|
+
}
|
|
329
|
+
if (!fs.existsSync(resolvedProjectPath)) {
|
|
330
|
+
throw new Error(`Project path '${resolvedProjectPath}' doesn't exist`);
|
|
331
|
+
}
|
|
332
|
+
if (verbose) {
|
|
333
|
+
console.log(`开始连接微信开发者工具,模式: ${mode}`);
|
|
334
|
+
console.log(`项目路径: ${resolvedProjectPath}`);
|
|
335
|
+
}
|
|
336
|
+
try {
|
|
337
|
+
switch (mode) {
|
|
338
|
+
case 'auto':
|
|
339
|
+
return await intelligentConnect(options, startTime);
|
|
340
|
+
case 'connect':
|
|
341
|
+
return await connectMode(options, startTime);
|
|
342
|
+
case 'launch':
|
|
343
|
+
return await launchMode(options, startTime);
|
|
344
|
+
default:
|
|
345
|
+
throw new Error(`不支持的连接模式: ${mode}`);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
catch (error) {
|
|
349
|
+
if (verbose) {
|
|
350
|
+
console.error(`连接失败:`, error);
|
|
351
|
+
}
|
|
352
|
+
throw error;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* 判断错误是否为可通过 connectMode 解决的会话冲突错误
|
|
357
|
+
*/
|
|
358
|
+
function isSessionConflictError(error) {
|
|
359
|
+
if (error instanceof DevToolsConnectionError) {
|
|
360
|
+
return error.details?.reason === 'session_conflict';
|
|
361
|
+
}
|
|
362
|
+
const message = error?.message || '';
|
|
363
|
+
return message.includes('already') ||
|
|
364
|
+
message.includes('session') ||
|
|
365
|
+
message.includes('conflict') ||
|
|
366
|
+
message.includes('automation');
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* 智能连接逻辑(优化版)
|
|
370
|
+
*
|
|
371
|
+
* 策略说明:
|
|
372
|
+
* 1. 默认使用 launchMode(依赖 automator.launch 的智能处理)
|
|
373
|
+
* - automator.launch 会自动检测IDE状态和项目匹配
|
|
374
|
+
* - 自动复用现有会话或打开新项目
|
|
375
|
+
* 2. 仅在会话冲突等特定错误时回退到 connectMode
|
|
376
|
+
* 3. 移除了复杂的端口检测和项目验证逻辑(交给官方库处理)
|
|
377
|
+
*/
|
|
378
|
+
async function intelligentConnect(options, startTime) {
|
|
379
|
+
if (options.verbose) {
|
|
380
|
+
console.log('🎯 智能连接策略: 优先使用 launchMode(自动处理项目验证和会话复用)');
|
|
381
|
+
}
|
|
382
|
+
try {
|
|
383
|
+
// 默认使用 launchMode
|
|
384
|
+
// automator.launch() 会自动:
|
|
385
|
+
// 1. 检测IDE是否运行
|
|
386
|
+
// 2. 验证项目路径是否匹配
|
|
387
|
+
// 3. 复用现有会话或打开新项目
|
|
388
|
+
return await launchMode(options, startTime);
|
|
389
|
+
}
|
|
390
|
+
catch (error) {
|
|
391
|
+
if (options.verbose) {
|
|
392
|
+
console.log('⚠️ launchMode 失败,分析错误类型...');
|
|
393
|
+
}
|
|
394
|
+
// 仅在特定可恢复错误时回退到 connectMode
|
|
395
|
+
if (options.fallbackMode && isSessionConflictError(error)) {
|
|
396
|
+
if (options.verbose) {
|
|
397
|
+
console.log('🔄 检测到会话冲突,尝试回退到 connectMode');
|
|
398
|
+
}
|
|
399
|
+
return await connectMode(options, startTime);
|
|
400
|
+
}
|
|
401
|
+
// 其他错误直接抛出
|
|
402
|
+
throw error;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Connect模式:两阶段连接
|
|
407
|
+
*/
|
|
408
|
+
async function connectMode(options, startTime) {
|
|
409
|
+
try {
|
|
410
|
+
// 阶段1: CLI启动
|
|
411
|
+
const startupResult = await executeWithDetailedError(() => startupPhase(options), 'startup');
|
|
412
|
+
// 阶段2: WebSocket连接
|
|
413
|
+
const connectionResult = await executeWithDetailedError(() => connectionPhase(options, startupResult), 'connection');
|
|
414
|
+
// 健康检查
|
|
415
|
+
let healthStatus = 'healthy';
|
|
416
|
+
if (options.healthCheck) {
|
|
417
|
+
healthStatus = await executeWithDetailedError(() => performHealthCheck(connectionResult.miniProgram), 'health_check');
|
|
418
|
+
}
|
|
419
|
+
return {
|
|
420
|
+
...connectionResult,
|
|
421
|
+
connectionMode: 'connect',
|
|
422
|
+
startupTime: Date.now() - startTime,
|
|
423
|
+
healthStatus,
|
|
424
|
+
processInfo: startupResult.processInfo
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
catch (error) {
|
|
428
|
+
// 检查是否是会话冲突错误
|
|
429
|
+
if (error instanceof DevToolsConnectionError &&
|
|
430
|
+
error.phase === 'startup' &&
|
|
431
|
+
error.details?.reason === 'session_conflict') {
|
|
432
|
+
if (options.verbose) {
|
|
433
|
+
console.log('🔄 检测到会话冲突,自动回退到传统连接模式(launch)...');
|
|
434
|
+
}
|
|
435
|
+
// 如果允许回退,自动使用launch模式
|
|
436
|
+
if (options.fallbackMode) {
|
|
437
|
+
return await launchMode(options, startTime);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
// 其他错误直接抛出
|
|
441
|
+
throw error;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* Launch模式:传统连接方式
|
|
446
|
+
*/
|
|
447
|
+
async function launchMode(options, startTime) {
|
|
448
|
+
const connectOptions = {
|
|
449
|
+
projectPath: options.projectPath,
|
|
450
|
+
cliPath: options.cliPath,
|
|
451
|
+
port: options.autoPort || options.port
|
|
452
|
+
};
|
|
453
|
+
const result = await connectDevtools(connectOptions);
|
|
454
|
+
// 健康检查
|
|
455
|
+
let healthStatus = 'healthy';
|
|
456
|
+
if (options.healthCheck) {
|
|
457
|
+
healthStatus = await executeWithDetailedError(() => performHealthCheck(result.miniProgram), 'health_check');
|
|
458
|
+
}
|
|
459
|
+
return {
|
|
460
|
+
...result,
|
|
461
|
+
connectionMode: 'launch',
|
|
462
|
+
startupTime: Date.now() - startTime,
|
|
463
|
+
healthStatus
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* 启动阶段:使用CLI命令启动自动化
|
|
468
|
+
*/
|
|
469
|
+
async function startupPhase(options) {
|
|
470
|
+
const port = options.autoPort || 9420;
|
|
471
|
+
const cliCommand = buildCliCommand(options);
|
|
472
|
+
if (options.verbose) {
|
|
473
|
+
console.log('执行CLI命令:', cliCommand.join(' '));
|
|
474
|
+
}
|
|
475
|
+
// 执行CLI命令
|
|
476
|
+
const process = await executeCliCommand(cliCommand);
|
|
477
|
+
// 等待WebSocket服务就绪
|
|
478
|
+
await waitForWebSocketReady(port, options.timeout || 45000, options.verbose);
|
|
479
|
+
return {
|
|
480
|
+
processInfo: {
|
|
481
|
+
pid: process.pid,
|
|
482
|
+
port
|
|
483
|
+
},
|
|
484
|
+
startTime: Date.now()
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* 连接阶段:连接到WebSocket
|
|
489
|
+
*/
|
|
490
|
+
async function connectionPhase(options, startupResult) {
|
|
491
|
+
const wsEndpoint = `ws://localhost:${startupResult.processInfo.port}`;
|
|
492
|
+
if (options.verbose) {
|
|
493
|
+
console.log('连接WebSocket端点:', wsEndpoint);
|
|
494
|
+
}
|
|
495
|
+
// 连接到WebSocket端点
|
|
496
|
+
const miniProgram = await connectWithRetry(wsEndpoint, 3);
|
|
497
|
+
// 获取当前页面
|
|
498
|
+
const currentPage = await miniProgram.currentPage();
|
|
499
|
+
if (!currentPage) {
|
|
500
|
+
throw new Error('无法获取当前页面');
|
|
501
|
+
}
|
|
502
|
+
const pagePath = await currentPage.path;
|
|
503
|
+
return {
|
|
504
|
+
miniProgram,
|
|
505
|
+
currentPage,
|
|
506
|
+
pagePath
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* 构建CLI命令
|
|
511
|
+
*/
|
|
512
|
+
function buildCliCommand(options) {
|
|
513
|
+
const cliPath = options.cliPath || findDefaultCliPath();
|
|
514
|
+
const resolvedProjectPath = resolveProjectPath(options.projectPath);
|
|
515
|
+
const args = ['auto', '--project', resolvedProjectPath];
|
|
516
|
+
// 使用正确的端口参数名(应该是 --auto-port 而不是 --port)
|
|
517
|
+
if (options.autoPort) {
|
|
518
|
+
args.push('--auto-port', options.autoPort.toString());
|
|
519
|
+
}
|
|
520
|
+
// 移除不存在的--auto-account参数
|
|
521
|
+
// autoAccount参数在官方CLI帮助中没有显示,可能已弃用
|
|
522
|
+
if (options.autoAccount) {
|
|
523
|
+
// 保留接口兼容性但不传递给CLI
|
|
524
|
+
console.warn('autoAccount参数可能不受支持,已忽略');
|
|
525
|
+
}
|
|
526
|
+
if (options.verbose) {
|
|
527
|
+
args.push('--debug');
|
|
528
|
+
}
|
|
529
|
+
return [cliPath, ...args];
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* 查找默认CLI路径
|
|
533
|
+
*/
|
|
534
|
+
function findDefaultCliPath() {
|
|
535
|
+
const platform = process.platform;
|
|
536
|
+
if (platform === 'darwin') {
|
|
537
|
+
return '/Applications/wechatwebdevtools.app/Contents/MacOS/cli';
|
|
538
|
+
}
|
|
539
|
+
else if (platform === 'win32') {
|
|
540
|
+
return 'C:/Program Files (x86)/Tencent/微信web开发者工具/cli.bat';
|
|
541
|
+
}
|
|
542
|
+
else {
|
|
543
|
+
throw new Error(`不支持的平台: ${platform}`);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* 解析项目路径
|
|
548
|
+
*/
|
|
549
|
+
function resolveProjectPath(projectPath) {
|
|
550
|
+
if (projectPath.startsWith('@playground/')) {
|
|
551
|
+
const relativePath = projectPath.replace('@playground/', 'playground/');
|
|
552
|
+
return path.resolve(process.cwd(), relativePath);
|
|
553
|
+
}
|
|
554
|
+
else if (!path.isAbsolute(projectPath)) {
|
|
555
|
+
return path.resolve(process.cwd(), projectPath);
|
|
556
|
+
}
|
|
557
|
+
return projectPath;
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* 执行CLI命令
|
|
561
|
+
*/
|
|
562
|
+
async function executeCliCommand(command) {
|
|
563
|
+
const [cliPath, ...args] = command;
|
|
564
|
+
return new Promise((resolve, reject) => {
|
|
565
|
+
const process = spawn(cliPath, args, {
|
|
566
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
567
|
+
});
|
|
568
|
+
let output = '';
|
|
569
|
+
let errorOutput = '';
|
|
570
|
+
let resolved = false;
|
|
571
|
+
if (process.stdout) {
|
|
572
|
+
process.stdout.on('data', (data) => {
|
|
573
|
+
const text = data.toString();
|
|
574
|
+
output += text;
|
|
575
|
+
console.log('[CLI stdout]:', text.trim());
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
if (process.stderr) {
|
|
579
|
+
process.stderr.on('data', (data) => {
|
|
580
|
+
const text = data.toString();
|
|
581
|
+
errorOutput += text;
|
|
582
|
+
console.log('[CLI stderr]:', text.trim());
|
|
583
|
+
// 检测端口冲突错误
|
|
584
|
+
if (text.includes('must be restarted on port')) {
|
|
585
|
+
const match = text.match(/started on .+:(\d+) and must be restarted on port (\d+)/);
|
|
586
|
+
if (match) {
|
|
587
|
+
const [, currentPort, requestedPort] = match;
|
|
588
|
+
if (!resolved) {
|
|
589
|
+
resolved = true;
|
|
590
|
+
process.kill();
|
|
591
|
+
reject(new Error(`端口冲突: IDE已在端口 ${currentPort} 上运行,但请求的端口是 ${requestedPort}。\n` +
|
|
592
|
+
`解决方案:\n` +
|
|
593
|
+
`1. 使用当前端口:autoPort: ${currentPort}\n` +
|
|
594
|
+
`2. 关闭微信开发者工具后重新连接`));
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
// 检测自动化会话冲突错误
|
|
599
|
+
if ((text.includes('automation') || text.includes('自动化')) &&
|
|
600
|
+
(text.includes('already') || text.includes('exists') || text.includes('已存在'))) {
|
|
601
|
+
if (!resolved) {
|
|
602
|
+
resolved = true;
|
|
603
|
+
process.kill();
|
|
604
|
+
// 创建特殊的会话冲突错误,允许上层处理回退
|
|
605
|
+
const sessionConflictError = new DevToolsConnectionError(`自动化会话冲突: 微信开发者工具已有活跃的自动化会话`, 'startup', undefined, {
|
|
606
|
+
reason: 'session_conflict',
|
|
607
|
+
suggestFallback: true,
|
|
608
|
+
details: `可能原因:\n` +
|
|
609
|
+
`1. 之前使用了 connect_devtools (传统模式) 并已建立连接\n` +
|
|
610
|
+
`2. 其他程序正在使用自动化功能\n` +
|
|
611
|
+
`解决方案:\n` +
|
|
612
|
+
`1. 使用已建立的连接(工具会自动检测并复用)\n` +
|
|
613
|
+
`2. 关闭微信开发者工具并重新打开\n` +
|
|
614
|
+
`3. 使用 connect_devtools 继续传统模式`
|
|
615
|
+
});
|
|
616
|
+
reject(sessionConflictError);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
// 检测 CLI 命令失败(通用)
|
|
620
|
+
if (text.includes('error') || text.includes('failed') || text.includes('失败')) {
|
|
621
|
+
if (!resolved && text.length > 10) { // 确保不是误报
|
|
622
|
+
console.log('[CLI 警告] 检测到潜在错误:', text.trim());
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
process.on('error', (error) => {
|
|
628
|
+
if (!resolved) {
|
|
629
|
+
resolved = true;
|
|
630
|
+
reject(new Error(`CLI命令执行失败: ${error.message}`));
|
|
631
|
+
}
|
|
632
|
+
});
|
|
633
|
+
process.on('exit', (code, signal) => {
|
|
634
|
+
if (!resolved && code !== 0 && code !== null) {
|
|
635
|
+
resolved = true;
|
|
636
|
+
const errorMsg = errorOutput || `CLI进程异常退出 (code=${code}, signal=${signal})`;
|
|
637
|
+
reject(new Error(errorMsg));
|
|
638
|
+
}
|
|
639
|
+
});
|
|
640
|
+
process.on('spawn', () => {
|
|
641
|
+
// CLI命令已启动,返回进程对象
|
|
642
|
+
if (!resolved) {
|
|
643
|
+
resolved = true;
|
|
644
|
+
resolve(process);
|
|
645
|
+
}
|
|
646
|
+
});
|
|
647
|
+
// 设置超时
|
|
648
|
+
setTimeout(() => {
|
|
649
|
+
if (!resolved && !process.killed) {
|
|
650
|
+
resolved = true;
|
|
651
|
+
process.kill();
|
|
652
|
+
reject(new Error('CLI命令启动超时'));
|
|
653
|
+
}
|
|
654
|
+
}, 10000);
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
/**
|
|
658
|
+
* 等待WebSocket服务就绪
|
|
659
|
+
* @public 导出供测试使用
|
|
660
|
+
*/
|
|
661
|
+
export async function waitForWebSocketReady(port, timeout, verbose = false) {
|
|
662
|
+
const startTime = Date.now();
|
|
663
|
+
let attempt = 0;
|
|
664
|
+
const maxAttempts = Math.ceil(timeout / 1000); // 每秒检查一次
|
|
665
|
+
if (verbose) {
|
|
666
|
+
console.log(`等待WebSocket服务启动,端口: ${port},超时: ${timeout}ms`);
|
|
667
|
+
}
|
|
668
|
+
while (Date.now() - startTime < timeout) {
|
|
669
|
+
attempt++;
|
|
670
|
+
if (verbose && attempt % 5 === 0) { // 每5秒显示一次进度
|
|
671
|
+
const elapsed = Date.now() - startTime;
|
|
672
|
+
console.log(`WebSocket检测进度: ${Math.round(elapsed / 1000)}s / ${Math.round(timeout / 1000)}s`);
|
|
673
|
+
}
|
|
674
|
+
// 尝试多种检测方式
|
|
675
|
+
const isReady = await checkDevToolsRunning(port) || await checkWebSocketDirectly(port);
|
|
676
|
+
if (isReady) {
|
|
677
|
+
if (verbose) {
|
|
678
|
+
const elapsed = Date.now() - startTime;
|
|
679
|
+
console.log(`WebSocket服务已启动,耗时: ${elapsed}ms`);
|
|
680
|
+
}
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
// 渐进式等待时间:前10次每500ms检查一次,之后每1000ms检查一次
|
|
684
|
+
const waitTime = attempt <= 10 ? 500 : 1000;
|
|
685
|
+
await sleep(waitTime);
|
|
686
|
+
}
|
|
687
|
+
const elapsed = Date.now() - startTime;
|
|
688
|
+
throw new Error(`WebSocket服务启动超时,端口: ${port},已等待: ${elapsed}ms`);
|
|
689
|
+
}
|
|
690
|
+
/**
|
|
691
|
+
* 直接尝试WebSocket连接检测
|
|
692
|
+
*/
|
|
693
|
+
async function checkWebSocketDirectly(port) {
|
|
694
|
+
return new Promise((resolve) => {
|
|
695
|
+
try {
|
|
696
|
+
// 尝试创建WebSocket连接
|
|
697
|
+
const ws = new (require('ws'))(`ws://localhost:${port}`);
|
|
698
|
+
const timeout = setTimeout(() => {
|
|
699
|
+
ws.close();
|
|
700
|
+
resolve(false);
|
|
701
|
+
}, 2000);
|
|
702
|
+
ws.on('open', () => {
|
|
703
|
+
clearTimeout(timeout);
|
|
704
|
+
ws.close();
|
|
705
|
+
resolve(true);
|
|
706
|
+
});
|
|
707
|
+
ws.on('error', () => {
|
|
708
|
+
clearTimeout(timeout);
|
|
709
|
+
resolve(false);
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
catch {
|
|
713
|
+
resolve(false);
|
|
714
|
+
}
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
/**
|
|
718
|
+
* 检查开发者工具是否运行
|
|
719
|
+
*/
|
|
720
|
+
export async function checkDevToolsRunning(port) {
|
|
721
|
+
try {
|
|
722
|
+
// 尝试连接WebSocket来检测服务状态
|
|
723
|
+
const response = await fetch(`http://localhost:${port}`, {
|
|
724
|
+
signal: AbortSignal.timeout(1000)
|
|
725
|
+
});
|
|
726
|
+
return response.ok;
|
|
727
|
+
}
|
|
728
|
+
catch {
|
|
729
|
+
return false;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
/**
|
|
733
|
+
* 自动检测当前IDE运行的端口
|
|
734
|
+
* 返回检测到的端口号,如果未检测到则返回 null
|
|
735
|
+
*/
|
|
736
|
+
export async function detectIDEPort(verbose = false) {
|
|
737
|
+
// 常用端口列表
|
|
738
|
+
const commonPorts = [9420, 9440, 9430, 9450, 9460];
|
|
739
|
+
if (verbose) {
|
|
740
|
+
console.log('🔍 检测微信开发者工具运行端口...');
|
|
741
|
+
}
|
|
742
|
+
// 策略1: 尝试常用端口
|
|
743
|
+
for (const port of commonPorts) {
|
|
744
|
+
if (verbose) {
|
|
745
|
+
console.log(` 检测端口 ${port}...`);
|
|
746
|
+
}
|
|
747
|
+
if (await checkDevToolsRunning(port)) {
|
|
748
|
+
if (verbose) {
|
|
749
|
+
console.log(`✅ 检测到IDE运行在端口 ${port}`);
|
|
750
|
+
}
|
|
751
|
+
return port;
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
// 策略2: 使用 lsof 命令检查(仅macOS/Linux)
|
|
755
|
+
if (process.platform === 'darwin' || process.platform === 'linux') {
|
|
756
|
+
try {
|
|
757
|
+
const { execSync } = await import('child_process');
|
|
758
|
+
// 查找微信开发者工具占用的端口,只检测9400-9500范围的自动化端口
|
|
759
|
+
const output = execSync("lsof -i -P | grep wechat | grep LISTEN | awk '{print $9}' | cut -d: -f2 | grep '^94[0-9][0-9]$'", { encoding: 'utf-8', timeout: 3000 }).trim();
|
|
760
|
+
if (output) {
|
|
761
|
+
const ports = output.split('\n').map((p) => parseInt(p, 10)).filter((p) => !isNaN(p));
|
|
762
|
+
if (verbose && ports.length > 0) {
|
|
763
|
+
console.log(` lsof检测到端口: ${ports.join(', ')}`);
|
|
764
|
+
}
|
|
765
|
+
// 遍历检测到的端口,验证是否为有效的自动化端口
|
|
766
|
+
for (const port of ports) {
|
|
767
|
+
if (port >= 9400 && port <= 9500) {
|
|
768
|
+
if (await checkDevToolsRunning(port)) {
|
|
769
|
+
if (verbose) {
|
|
770
|
+
console.log(`✅ 通过lsof检测到IDE运行在端口 ${port}`);
|
|
771
|
+
}
|
|
772
|
+
return port;
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
catch (error) {
|
|
779
|
+
// lsof 失败,继续
|
|
780
|
+
if (verbose) {
|
|
781
|
+
console.log(' lsof检测失败');
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
if (verbose) {
|
|
786
|
+
console.log('❌ 未检测到IDE运行端口');
|
|
787
|
+
}
|
|
788
|
+
return null;
|
|
789
|
+
}
|
|
790
|
+
/**
|
|
791
|
+
* 带重试的WebSocket连接
|
|
792
|
+
*/
|
|
793
|
+
async function connectWithRetry(wsEndpoint, maxRetries) {
|
|
794
|
+
for (let i = 0; i < maxRetries; i++) {
|
|
795
|
+
try {
|
|
796
|
+
return await automator.connect({ wsEndpoint });
|
|
797
|
+
}
|
|
798
|
+
catch (error) {
|
|
799
|
+
if (i === maxRetries - 1) {
|
|
800
|
+
throw error;
|
|
801
|
+
}
|
|
802
|
+
// 指数退避重试
|
|
803
|
+
await sleep(1000 * Math.pow(2, i));
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
/**
|
|
808
|
+
* 执行健康检查
|
|
809
|
+
*/
|
|
810
|
+
async function performHealthCheck(miniProgram) {
|
|
811
|
+
try {
|
|
812
|
+
// 检查基本连接
|
|
813
|
+
const currentPage = await miniProgram.currentPage();
|
|
814
|
+
if (!currentPage) {
|
|
815
|
+
return 'unhealthy';
|
|
816
|
+
}
|
|
817
|
+
// 检查页面响应
|
|
818
|
+
const path = await currentPage.path;
|
|
819
|
+
if (!path) {
|
|
820
|
+
return 'degraded';
|
|
821
|
+
}
|
|
822
|
+
return 'healthy';
|
|
823
|
+
}
|
|
824
|
+
catch {
|
|
825
|
+
return 'unhealthy';
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
/**
|
|
829
|
+
* 带详细错误信息的执行包装器
|
|
830
|
+
*/
|
|
831
|
+
async function executeWithDetailedError(operation, phase) {
|
|
832
|
+
try {
|
|
833
|
+
return await operation();
|
|
834
|
+
}
|
|
835
|
+
catch (error) {
|
|
836
|
+
const originalError = error instanceof Error ? error : new Error(String(error));
|
|
837
|
+
// 保留原始错误消息,不要用通用的"阶段失败"覆盖
|
|
838
|
+
throw new DevToolsConnectionError(originalError.message, phase, originalError, { timestamp: new Date().toISOString() });
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
/**
|
|
842
|
+
* 生成元素的唯一标识符 (uid)
|
|
843
|
+
*/
|
|
844
|
+
export async function generateElementUid(element, index) {
|
|
845
|
+
try {
|
|
846
|
+
const tagName = element.tagName;
|
|
847
|
+
const className = await element.attribute('class').catch(() => '');
|
|
848
|
+
const id = await element.attribute('id').catch(() => '');
|
|
849
|
+
console.log(`[generateElementUid] tagName=${tagName}, className="${className}", id="${id}", index=${index}`);
|
|
850
|
+
let selector = tagName;
|
|
851
|
+
if (id) {
|
|
852
|
+
selector += `#${id}`;
|
|
853
|
+
}
|
|
854
|
+
else if (className) {
|
|
855
|
+
selector += `.${className.split(' ')[0]}`;
|
|
856
|
+
}
|
|
857
|
+
else {
|
|
858
|
+
selector += `:nth-child(${index + 1})`;
|
|
859
|
+
}
|
|
860
|
+
console.log(`[generateElementUid] Generated UID: ${selector}`);
|
|
861
|
+
return selector;
|
|
862
|
+
}
|
|
863
|
+
catch (error) {
|
|
864
|
+
console.log(`[generateElementUid] Error:`, error);
|
|
865
|
+
return `${element.tagName || 'unknown'}:nth-child(${index + 1})`;
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
/**
|
|
869
|
+
* 获取页面元素快照
|
|
870
|
+
*
|
|
871
|
+
* @param page 页面对象
|
|
872
|
+
* @returns 页面快照和元素映射
|
|
873
|
+
*/
|
|
874
|
+
export async function getPageSnapshot(page) {
|
|
875
|
+
if (!page) {
|
|
876
|
+
throw new Error("页面对象是必需的");
|
|
877
|
+
}
|
|
878
|
+
try {
|
|
879
|
+
const elements = [];
|
|
880
|
+
const elementMap = new Map();
|
|
881
|
+
// 等待页面加载完成
|
|
882
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
883
|
+
// 尝试多种选择器策略获取元素
|
|
884
|
+
let childElements = [];
|
|
885
|
+
// 策略1: 尝试获取所有元素
|
|
886
|
+
try {
|
|
887
|
+
childElements = await page.$$('*');
|
|
888
|
+
console.log(`策略1 (*) 获取到 ${childElements.length} 个元素`);
|
|
889
|
+
}
|
|
890
|
+
catch (error) {
|
|
891
|
+
console.log('策略1 (*) 失败:', error);
|
|
892
|
+
}
|
|
893
|
+
// 策略2: 如果策略1失败,尝试小程序常用组件
|
|
894
|
+
if (childElements.length === 0) {
|
|
895
|
+
const commonSelectors = [
|
|
896
|
+
'view', 'text', 'button', 'image', 'input', 'textarea', 'picker', 'switch',
|
|
897
|
+
'slider', 'scroll-view', 'swiper', 'icon', 'rich-text', 'progress',
|
|
898
|
+
'navigator', 'form', 'checkbox', 'radio', 'cover-view', 'cover-image'
|
|
899
|
+
];
|
|
900
|
+
for (const selector of commonSelectors) {
|
|
901
|
+
try {
|
|
902
|
+
const elements = await page.$$(selector);
|
|
903
|
+
childElements.push(...elements);
|
|
904
|
+
console.log(`策略2 (${selector}) 获取到 ${elements.length} 个元素`);
|
|
905
|
+
}
|
|
906
|
+
catch (error) {
|
|
907
|
+
console.log(`策略2 (${selector}) 失败:`, error);
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
// 策略3: 如果还是没有元素,尝试根据层级查找
|
|
912
|
+
if (childElements.length === 0) {
|
|
913
|
+
try {
|
|
914
|
+
const rootElements = await page.$$('page > *');
|
|
915
|
+
childElements = rootElements;
|
|
916
|
+
console.log(`策略3 (page > *) 获取到 ${childElements.length} 个元素`);
|
|
917
|
+
}
|
|
918
|
+
catch (error) {
|
|
919
|
+
console.log('策略3 (page > *) 失败:', error);
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
console.log(`最终获取到 ${childElements.length} 个元素`);
|
|
923
|
+
// 用于跟踪每个基础选择器的元素计数
|
|
924
|
+
const selectorIndexMap = new Map();
|
|
925
|
+
for (let i = 0; i < childElements.length; i++) {
|
|
926
|
+
const element = childElements[i];
|
|
927
|
+
try {
|
|
928
|
+
const uid = await generateElementUid(element, i);
|
|
929
|
+
const snapshot = {
|
|
930
|
+
uid,
|
|
931
|
+
tagName: element.tagName || 'unknown',
|
|
932
|
+
};
|
|
933
|
+
// 获取元素文本
|
|
934
|
+
try {
|
|
935
|
+
const text = await element.text();
|
|
936
|
+
if (text && text.trim()) {
|
|
937
|
+
snapshot.text = text.trim();
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
catch (error) {
|
|
941
|
+
// 忽略无法获取文本的元素
|
|
942
|
+
}
|
|
943
|
+
// 获取元素位置信息
|
|
944
|
+
try {
|
|
945
|
+
const [size, offset] = await Promise.all([
|
|
946
|
+
element.size(),
|
|
947
|
+
element.offset()
|
|
948
|
+
]);
|
|
949
|
+
snapshot.position = {
|
|
950
|
+
left: offset.left,
|
|
951
|
+
top: offset.top,
|
|
952
|
+
width: size.width,
|
|
953
|
+
height: size.height
|
|
954
|
+
};
|
|
955
|
+
}
|
|
956
|
+
catch (error) {
|
|
957
|
+
// 忽略无法获取位置的元素
|
|
958
|
+
}
|
|
959
|
+
// 获取常用属性
|
|
960
|
+
try {
|
|
961
|
+
const attributes = {};
|
|
962
|
+
const commonAttrs = ['class', 'id', 'data-*'];
|
|
963
|
+
for (const attr of commonAttrs) {
|
|
964
|
+
try {
|
|
965
|
+
const value = await element.attribute(attr);
|
|
966
|
+
if (value) {
|
|
967
|
+
attributes[attr] = value;
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
catch (error) {
|
|
971
|
+
// 忽略不存在的属性
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
if (Object.keys(attributes).length > 0) {
|
|
975
|
+
snapshot.attributes = attributes;
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
catch (error) {
|
|
979
|
+
// 忽略属性获取错误
|
|
980
|
+
}
|
|
981
|
+
elements.push(snapshot);
|
|
982
|
+
// 生成可查询的基础选择器(不包含伪类)
|
|
983
|
+
const tagName = element.tagName;
|
|
984
|
+
const className = await element.attribute('class').catch(() => '');
|
|
985
|
+
const id = await element.attribute('id').catch(() => '');
|
|
986
|
+
let baseSelector = tagName;
|
|
987
|
+
if (id) {
|
|
988
|
+
baseSelector = `${tagName}#${id}`;
|
|
989
|
+
}
|
|
990
|
+
else if (className) {
|
|
991
|
+
baseSelector = `${tagName}.${className.split(' ')[0]}`;
|
|
992
|
+
}
|
|
993
|
+
// 计算该选择器的元素索引(递增计数)
|
|
994
|
+
const currentIndex = selectorIndexMap.get(baseSelector) || 0;
|
|
995
|
+
selectorIndexMap.set(baseSelector, currentIndex + 1);
|
|
996
|
+
// 存储 ElementMapInfo,使用可查询的基础选择器和索引
|
|
997
|
+
elementMap.set(uid, {
|
|
998
|
+
selector: baseSelector, // 使用可查询的基础选择器
|
|
999
|
+
index: currentIndex // 使用该选择器的当前计数
|
|
1000
|
+
});
|
|
1001
|
+
}
|
|
1002
|
+
catch (error) {
|
|
1003
|
+
console.warn(`Error processing element ${i}:`, error);
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
const pagePath = await page.path;
|
|
1007
|
+
const snapshot = {
|
|
1008
|
+
path: pagePath,
|
|
1009
|
+
elements
|
|
1010
|
+
};
|
|
1011
|
+
return { snapshot, elementMap };
|
|
1012
|
+
}
|
|
1013
|
+
catch (error) {
|
|
1014
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1015
|
+
throw new Error(`获取页面快照失败: ${errorMessage}`);
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
/**
|
|
1019
|
+
* 点击页面元素
|
|
1020
|
+
*
|
|
1021
|
+
* @param page 页面对象
|
|
1022
|
+
* @param elementMap 元素映射
|
|
1023
|
+
* @param options 点击选项
|
|
1024
|
+
*/
|
|
1025
|
+
export async function clickElement(page, elementMap, options) {
|
|
1026
|
+
const { uid, dblClick = false } = options;
|
|
1027
|
+
if (!uid) {
|
|
1028
|
+
throw new Error("元素uid是必需的");
|
|
1029
|
+
}
|
|
1030
|
+
if (!page) {
|
|
1031
|
+
throw new Error("页面对象是必需的");
|
|
1032
|
+
}
|
|
1033
|
+
try {
|
|
1034
|
+
// 通过uid查找元素映射信息
|
|
1035
|
+
const mapInfo = elementMap.get(uid);
|
|
1036
|
+
if (!mapInfo) {
|
|
1037
|
+
throw new Error(`找不到uid为 ${uid} 的元素,请先获取页面快照`);
|
|
1038
|
+
}
|
|
1039
|
+
console.log(`[Click] 准备点击元素 - UID: ${uid}, Selector: ${mapInfo.selector}, Index: ${mapInfo.index}`);
|
|
1040
|
+
// 使用选择器获取所有匹配元素
|
|
1041
|
+
const elements = await page.$$(mapInfo.selector);
|
|
1042
|
+
if (!elements || elements.length === 0) {
|
|
1043
|
+
throw new Error(`无法找到选择器为 ${mapInfo.selector} 的元素`);
|
|
1044
|
+
}
|
|
1045
|
+
// 检查索引是否有效
|
|
1046
|
+
if (mapInfo.index >= elements.length) {
|
|
1047
|
+
throw new Error(`元素索引 ${mapInfo.index} 超出范围,共找到 ${elements.length} 个元素`);
|
|
1048
|
+
}
|
|
1049
|
+
// 通过索引获取目标元素
|
|
1050
|
+
const element = elements[mapInfo.index];
|
|
1051
|
+
if (!element) {
|
|
1052
|
+
throw new Error(`无法获取索引为 ${mapInfo.index} 的元素`);
|
|
1053
|
+
}
|
|
1054
|
+
// 记录点击前的页面路径
|
|
1055
|
+
const beforePath = await page.path;
|
|
1056
|
+
console.log(`[Click] 点击前页面: ${beforePath}`);
|
|
1057
|
+
// 执行点击操作
|
|
1058
|
+
await element.tap();
|
|
1059
|
+
console.log(`[Click] 已执行 tap() 操作`);
|
|
1060
|
+
// 如果是双击,再点击一次
|
|
1061
|
+
if (dblClick) {
|
|
1062
|
+
await new Promise(resolve => setTimeout(resolve, 100)); // 短暂延迟
|
|
1063
|
+
await element.tap();
|
|
1064
|
+
console.log(`[Click] 已执行第二次 tap() (双击)`);
|
|
1065
|
+
}
|
|
1066
|
+
// 等待一小段时间,让页面有机会响应
|
|
1067
|
+
await new Promise(resolve => setTimeout(resolve, 300));
|
|
1068
|
+
// 记录点击后的页面路径
|
|
1069
|
+
try {
|
|
1070
|
+
const afterPath = await page.path;
|
|
1071
|
+
console.log(`[Click] 点击后页面: ${afterPath}`);
|
|
1072
|
+
if (beforePath !== afterPath) {
|
|
1073
|
+
console.log(`[Click] ✅ 页面已切换: ${beforePath} → ${afterPath}`);
|
|
1074
|
+
}
|
|
1075
|
+
else {
|
|
1076
|
+
console.log(`[Click] ⚠️ 页面未切换,可能是同页面操作或导航延迟`);
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
catch (error) {
|
|
1080
|
+
console.warn(`[Click] 无法获取点击后的页面路径:`, error);
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
catch (error) {
|
|
1084
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1085
|
+
console.error(`[Click] 点击失败:`, error);
|
|
1086
|
+
throw new Error(`点击元素失败: ${errorMessage}`);
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
/**
|
|
1090
|
+
* 页面截图
|
|
1091
|
+
*
|
|
1092
|
+
* @param miniProgram MiniProgram 对象
|
|
1093
|
+
* @param options 截图选项
|
|
1094
|
+
* @returns 如果没有指定路径,返回base64数据;否则返回undefined
|
|
1095
|
+
*/
|
|
1096
|
+
export async function takeScreenshot(miniProgram, options = {}) {
|
|
1097
|
+
if (!miniProgram) {
|
|
1098
|
+
throw new Error("MiniProgram对象是必需的");
|
|
1099
|
+
}
|
|
1100
|
+
try {
|
|
1101
|
+
const { path } = options;
|
|
1102
|
+
// 确保页面完全加载和稳定
|
|
1103
|
+
try {
|
|
1104
|
+
console.log('获取当前页面并等待稳定...');
|
|
1105
|
+
const currentPage = await miniProgram.currentPage();
|
|
1106
|
+
if (currentPage && typeof currentPage.waitFor === 'function') {
|
|
1107
|
+
// 等待页面稳定,增加等待时间
|
|
1108
|
+
await currentPage.waitFor(1000);
|
|
1109
|
+
console.log('页面等待完成');
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
catch (waitError) {
|
|
1113
|
+
console.warn('页面等待失败,继续尝试截图:', waitError);
|
|
1114
|
+
}
|
|
1115
|
+
// 重试机制执行截图
|
|
1116
|
+
let result;
|
|
1117
|
+
let lastError;
|
|
1118
|
+
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
1119
|
+
try {
|
|
1120
|
+
console.log(`截图尝试 ${attempt}/3`);
|
|
1121
|
+
if (path) {
|
|
1122
|
+
// 保存到指定路径
|
|
1123
|
+
await miniProgram.screenshot({ path });
|
|
1124
|
+
result = undefined;
|
|
1125
|
+
console.log(`截图保存成功: ${path}`);
|
|
1126
|
+
break;
|
|
1127
|
+
}
|
|
1128
|
+
else {
|
|
1129
|
+
// 返回base64数据
|
|
1130
|
+
const base64Data = await miniProgram.screenshot();
|
|
1131
|
+
console.log('截图API调用完成,检查返回数据...');
|
|
1132
|
+
if (base64Data && typeof base64Data === 'string' && base64Data.length > 0) {
|
|
1133
|
+
result = base64Data;
|
|
1134
|
+
console.log(`截图成功,数据长度: ${base64Data.length}`);
|
|
1135
|
+
break;
|
|
1136
|
+
}
|
|
1137
|
+
else {
|
|
1138
|
+
throw new Error(`截图返回无效数据: ${typeof base64Data}, 长度: ${base64Data ? base64Data.length : 'null'}`);
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
catch (error) {
|
|
1143
|
+
lastError = error instanceof Error ? error : new Error(String(error));
|
|
1144
|
+
console.warn(`截图尝试 ${attempt} 失败:`, lastError.message);
|
|
1145
|
+
if (attempt < 3) {
|
|
1146
|
+
// 重试前等待更长时间,让页面稳定
|
|
1147
|
+
console.log(`等待 ${1000 + attempt * 500}ms 后重试...`);
|
|
1148
|
+
await new Promise(resolve => setTimeout(resolve, 1000 + attempt * 500));
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
if (!result && !path) {
|
|
1153
|
+
const troubleshootingTips = `
|
|
1154
|
+
|
|
1155
|
+
⚠️ 截图功能故障排除建议:
|
|
1156
|
+
1. 确保微信开发者工具处于**模拟器模式**(非真机调试)
|
|
1157
|
+
2. 检查工具设置:
|
|
1158
|
+
- 设置 → 安全设置 → 服务端口 ✅
|
|
1159
|
+
- 设置 → 通用设置 → 自动化测试 ✅
|
|
1160
|
+
3. 检查 macOS 系统权限:
|
|
1161
|
+
- 系统偏好设置 → 安全性与隐私 → 隐私 → 屏幕录制
|
|
1162
|
+
- 确保微信开发者工具在允许列表中
|
|
1163
|
+
4. 尝试重启微信开发者工具
|
|
1164
|
+
5. 查看详细文档: docs/SCREENSHOT_ISSUE.md
|
|
1165
|
+
|
|
1166
|
+
最后错误: ${lastError?.message || '未知错误'}`;
|
|
1167
|
+
throw new Error(`截图失败,已重试3次${troubleshootingTips}`);
|
|
1168
|
+
}
|
|
1169
|
+
return result;
|
|
1170
|
+
}
|
|
1171
|
+
catch (error) {
|
|
1172
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1173
|
+
// 如果错误信息已包含故障排除建议,直接抛出
|
|
1174
|
+
if (errorMessage.includes('故障排除建议')) {
|
|
1175
|
+
throw error;
|
|
1176
|
+
}
|
|
1177
|
+
// 否则添加简要提示
|
|
1178
|
+
throw new Error(`${errorMessage}\n\n提示: 查看 docs/SCREENSHOT_ISSUE.md 了解详细的故障排除方法`);
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
/**
|
|
1182
|
+
* 通过选择器查询页面元素
|
|
1183
|
+
*
|
|
1184
|
+
* @param page 页面对象
|
|
1185
|
+
* @param elementMap 元素映射
|
|
1186
|
+
* @param options 查询选项
|
|
1187
|
+
* @returns 匹配元素的信息数组
|
|
1188
|
+
*/
|
|
1189
|
+
export async function queryElements(page, elementMap, options) {
|
|
1190
|
+
const { selector } = options;
|
|
1191
|
+
if (!selector || typeof selector !== 'string' || selector.trim() === '') {
|
|
1192
|
+
throw new Error("选择器不能为空");
|
|
1193
|
+
}
|
|
1194
|
+
if (!page) {
|
|
1195
|
+
throw new Error("页面对象是必需的");
|
|
1196
|
+
}
|
|
1197
|
+
try {
|
|
1198
|
+
// 通过选择器查找元素
|
|
1199
|
+
const elements = await page.$$(selector);
|
|
1200
|
+
const results = [];
|
|
1201
|
+
// 用于跟踪 UID 冲突
|
|
1202
|
+
const uidCounter = new Map();
|
|
1203
|
+
for (let i = 0; i < elements.length; i++) {
|
|
1204
|
+
const element = elements[i];
|
|
1205
|
+
try {
|
|
1206
|
+
// 使用 generateElementUid 生成基础 UID
|
|
1207
|
+
const baseUid = await generateElementUid(element, i);
|
|
1208
|
+
// 检测 UID 冲突并添加 [N] 后缀
|
|
1209
|
+
const count = uidCounter.get(baseUid) || 0;
|
|
1210
|
+
uidCounter.set(baseUid, count + 1);
|
|
1211
|
+
// 第一个元素不加后缀,后续元素添加 [N] 后缀
|
|
1212
|
+
const uid = count === 0 ? baseUid : `${baseUid}[${count + 1}]`;
|
|
1213
|
+
const result = {
|
|
1214
|
+
uid,
|
|
1215
|
+
tagName: element.tagName || 'unknown',
|
|
1216
|
+
};
|
|
1217
|
+
// 获取元素文本
|
|
1218
|
+
try {
|
|
1219
|
+
const text = await element.text();
|
|
1220
|
+
if (text && text.trim()) {
|
|
1221
|
+
result.text = text.trim();
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
catch (error) {
|
|
1225
|
+
// 忽略无法获取文本的元素
|
|
1226
|
+
}
|
|
1227
|
+
// 获取元素位置信息
|
|
1228
|
+
try {
|
|
1229
|
+
const [size, offset] = await Promise.all([
|
|
1230
|
+
element.size(),
|
|
1231
|
+
element.offset()
|
|
1232
|
+
]);
|
|
1233
|
+
result.position = {
|
|
1234
|
+
left: offset.left,
|
|
1235
|
+
top: offset.top,
|
|
1236
|
+
width: size.width,
|
|
1237
|
+
height: size.height
|
|
1238
|
+
};
|
|
1239
|
+
}
|
|
1240
|
+
catch (error) {
|
|
1241
|
+
// 忽略无法获取位置的元素
|
|
1242
|
+
}
|
|
1243
|
+
// 获取常用属性
|
|
1244
|
+
try {
|
|
1245
|
+
const attributes = {};
|
|
1246
|
+
const commonAttrs = ['class', 'id', 'data-testid'];
|
|
1247
|
+
for (const attr of commonAttrs) {
|
|
1248
|
+
try {
|
|
1249
|
+
const value = await element.attribute(attr);
|
|
1250
|
+
if (value) {
|
|
1251
|
+
attributes[attr] = value;
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
catch (error) {
|
|
1255
|
+
// 忽略不存在的属性
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
if (Object.keys(attributes).length > 0) {
|
|
1259
|
+
result.attributes = attributes;
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
catch (error) {
|
|
1263
|
+
// 忽略属性获取错误
|
|
1264
|
+
}
|
|
1265
|
+
results.push(result);
|
|
1266
|
+
// 填充 elementMap:使用原始查询选择器和数组索引
|
|
1267
|
+
elementMap.set(uid, {
|
|
1268
|
+
selector: selector, // 使用原始查询选择器,而不是 baseUid
|
|
1269
|
+
index: i // 使用在查询结果中的索引位置
|
|
1270
|
+
});
|
|
1271
|
+
}
|
|
1272
|
+
catch (error) {
|
|
1273
|
+
console.warn(`Error processing element ${i}:`, error);
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
return results;
|
|
1277
|
+
}
|
|
1278
|
+
catch (error) {
|
|
1279
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1280
|
+
throw new Error(`查询元素失败: ${errorMessage}`);
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
/**
|
|
1284
|
+
* 等待条件满足
|
|
1285
|
+
*
|
|
1286
|
+
* @param page 页面对象
|
|
1287
|
+
* @param options 等待选项
|
|
1288
|
+
* @returns 等待结果
|
|
1289
|
+
*/
|
|
1290
|
+
export async function waitForCondition(page, options) {
|
|
1291
|
+
if (!page) {
|
|
1292
|
+
throw new Error("页面对象是必需的");
|
|
1293
|
+
}
|
|
1294
|
+
try {
|
|
1295
|
+
// 处理简单的数字超时
|
|
1296
|
+
if (typeof options === 'number') {
|
|
1297
|
+
await page.waitFor(options);
|
|
1298
|
+
return true;
|
|
1299
|
+
}
|
|
1300
|
+
// 处理简单的选择器字符串
|
|
1301
|
+
if (typeof options === 'string') {
|
|
1302
|
+
const startTime = Date.now();
|
|
1303
|
+
const timeout = 5000; // 默认5秒超时
|
|
1304
|
+
while (Date.now() - startTime < timeout) {
|
|
1305
|
+
try {
|
|
1306
|
+
const element = await page.$(options);
|
|
1307
|
+
if (element) {
|
|
1308
|
+
return true;
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
catch (error) {
|
|
1312
|
+
// 继续等待
|
|
1313
|
+
}
|
|
1314
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
1315
|
+
}
|
|
1316
|
+
throw new Error(`等待元素 ${options} 超时`);
|
|
1317
|
+
}
|
|
1318
|
+
// 处理复杂的等待条件对象
|
|
1319
|
+
const { selector, timeout = 5000, text, visible, disappear = false } = options;
|
|
1320
|
+
const startTime = Date.now();
|
|
1321
|
+
while (Date.now() - startTime < timeout) {
|
|
1322
|
+
try {
|
|
1323
|
+
if (selector) {
|
|
1324
|
+
const element = await page.$(selector);
|
|
1325
|
+
if (disappear) {
|
|
1326
|
+
// 等待元素消失
|
|
1327
|
+
if (!element) {
|
|
1328
|
+
return true;
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
else {
|
|
1332
|
+
// 等待元素出现
|
|
1333
|
+
if (element) {
|
|
1334
|
+
// 检查文本匹配
|
|
1335
|
+
if (text) {
|
|
1336
|
+
try {
|
|
1337
|
+
const elementText = await element.text();
|
|
1338
|
+
if (!elementText || !elementText.includes(text)) {
|
|
1339
|
+
throw new Error('文本不匹配');
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
catch (error) {
|
|
1343
|
+
throw new Error('文本不匹配');
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
// 检查可见性
|
|
1347
|
+
if (visible !== undefined) {
|
|
1348
|
+
try {
|
|
1349
|
+
const size = await element.size();
|
|
1350
|
+
const isVisible = size.width > 0 && size.height > 0;
|
|
1351
|
+
if (isVisible !== visible) {
|
|
1352
|
+
throw new Error('可见性不匹配');
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
catch (error) {
|
|
1356
|
+
throw new Error('可见性不匹配');
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
return true;
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
else if (typeof timeout === 'number') {
|
|
1364
|
+
// 简单的时间等待
|
|
1365
|
+
await page.waitFor(timeout);
|
|
1366
|
+
return true;
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
catch (error) {
|
|
1370
|
+
// 继续等待,直到超时
|
|
1371
|
+
}
|
|
1372
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
1373
|
+
}
|
|
1374
|
+
// 构建错误信息
|
|
1375
|
+
let errorMsg = '等待条件超时: ';
|
|
1376
|
+
if (selector) {
|
|
1377
|
+
errorMsg += `选择器 ${selector}`;
|
|
1378
|
+
if (disappear)
|
|
1379
|
+
errorMsg += ' 消失';
|
|
1380
|
+
if (text)
|
|
1381
|
+
errorMsg += ` 包含文本 "${text}"`;
|
|
1382
|
+
if (visible !== undefined)
|
|
1383
|
+
errorMsg += ` ${visible ? '可见' : '隐藏'}`;
|
|
1384
|
+
}
|
|
1385
|
+
throw new Error(errorMsg);
|
|
1386
|
+
}
|
|
1387
|
+
catch (error) {
|
|
1388
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1389
|
+
throw new Error(`等待条件失败: ${errorMessage}`);
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
/**
|
|
1393
|
+
* 向元素输入文本
|
|
1394
|
+
*
|
|
1395
|
+
* @param page 页面对象
|
|
1396
|
+
* @param elementMap 元素映射
|
|
1397
|
+
* @param options 输入选项
|
|
1398
|
+
*/
|
|
1399
|
+
export async function inputText(page, elementMap, options) {
|
|
1400
|
+
const { uid, text, clear = false, append = false } = options;
|
|
1401
|
+
if (!uid) {
|
|
1402
|
+
throw new Error("元素uid是必需的");
|
|
1403
|
+
}
|
|
1404
|
+
if (!page) {
|
|
1405
|
+
throw new Error("页面对象是必需的");
|
|
1406
|
+
}
|
|
1407
|
+
try {
|
|
1408
|
+
// 通过uid查找元素映射信息
|
|
1409
|
+
const mapInfo = elementMap.get(uid);
|
|
1410
|
+
if (!mapInfo) {
|
|
1411
|
+
throw new Error(`找不到uid为 ${uid} 的元素,请先获取页面快照`);
|
|
1412
|
+
}
|
|
1413
|
+
// 使用选择器获取所有匹配元素
|
|
1414
|
+
const elements = await page.$$(mapInfo.selector);
|
|
1415
|
+
if (!elements || elements.length === 0) {
|
|
1416
|
+
throw new Error(`无法找到选择器为 ${mapInfo.selector} 的元素`);
|
|
1417
|
+
}
|
|
1418
|
+
// 检查索引是否有效
|
|
1419
|
+
if (mapInfo.index >= elements.length) {
|
|
1420
|
+
throw new Error(`元素索引 ${mapInfo.index} 超出范围,共找到 ${elements.length} 个元素`);
|
|
1421
|
+
}
|
|
1422
|
+
// 通过索引获取目标元素
|
|
1423
|
+
const element = elements[mapInfo.index];
|
|
1424
|
+
if (!element) {
|
|
1425
|
+
throw new Error(`无法获取索引为 ${mapInfo.index} 的元素`);
|
|
1426
|
+
}
|
|
1427
|
+
// 清空元素(如果需要)
|
|
1428
|
+
if (clear && !append) {
|
|
1429
|
+
await element.clear();
|
|
1430
|
+
}
|
|
1431
|
+
// 输入文本
|
|
1432
|
+
if (append) {
|
|
1433
|
+
// 追加模式:先获取现有值
|
|
1434
|
+
const currentValue = await element.value().catch(() => '');
|
|
1435
|
+
await element.input(currentValue + text);
|
|
1436
|
+
}
|
|
1437
|
+
else {
|
|
1438
|
+
await element.input(text);
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
catch (error) {
|
|
1442
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1443
|
+
throw new Error(`文本输入失败: ${errorMessage}`);
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
/**
|
|
1447
|
+
* 获取元素值
|
|
1448
|
+
*
|
|
1449
|
+
* @param page 页面对象
|
|
1450
|
+
* @param elementMap 元素映射
|
|
1451
|
+
* @param options 获取选项
|
|
1452
|
+
* @returns 元素值
|
|
1453
|
+
*/
|
|
1454
|
+
export async function getElementValue(page, elementMap, options) {
|
|
1455
|
+
const { uid, attribute } = options;
|
|
1456
|
+
if (!uid) {
|
|
1457
|
+
throw new Error("元素uid是必需的");
|
|
1458
|
+
}
|
|
1459
|
+
if (!page) {
|
|
1460
|
+
throw new Error("页面对象是必需的");
|
|
1461
|
+
}
|
|
1462
|
+
try {
|
|
1463
|
+
// 通过uid查找元素映射信息
|
|
1464
|
+
const mapInfo = elementMap.get(uid);
|
|
1465
|
+
if (!mapInfo) {
|
|
1466
|
+
throw new Error(`找不到uid为 ${uid} 的元素,请先获取页面快照`);
|
|
1467
|
+
}
|
|
1468
|
+
// 使用选择器获取所有匹配元素
|
|
1469
|
+
const elements = await page.$$(mapInfo.selector);
|
|
1470
|
+
if (!elements || elements.length === 0) {
|
|
1471
|
+
throw new Error(`无法找到选择器为 ${mapInfo.selector} 的元素`);
|
|
1472
|
+
}
|
|
1473
|
+
// 检查索引是否有效
|
|
1474
|
+
if (mapInfo.index >= elements.length) {
|
|
1475
|
+
throw new Error(`元素索引 ${mapInfo.index} 超出范围,共找到 ${elements.length} 个元素`);
|
|
1476
|
+
}
|
|
1477
|
+
// 通过索引获取目标元素
|
|
1478
|
+
const element = elements[mapInfo.index];
|
|
1479
|
+
if (!element) {
|
|
1480
|
+
throw new Error(`无法获取索引为 ${mapInfo.index} 的元素`);
|
|
1481
|
+
}
|
|
1482
|
+
// 获取值
|
|
1483
|
+
if (attribute) {
|
|
1484
|
+
return await element.attribute(attribute);
|
|
1485
|
+
}
|
|
1486
|
+
else {
|
|
1487
|
+
// 尝试获取value属性,如果失败则获取text
|
|
1488
|
+
try {
|
|
1489
|
+
return await element.value();
|
|
1490
|
+
}
|
|
1491
|
+
catch (error) {
|
|
1492
|
+
return await element.text();
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
catch (error) {
|
|
1497
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1498
|
+
throw new Error(`获取元素值失败: ${errorMessage}`);
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
/**
|
|
1502
|
+
* 设置表单控件值
|
|
1503
|
+
*
|
|
1504
|
+
* @param page 页面对象
|
|
1505
|
+
* @param elementMap 元素映射
|
|
1506
|
+
* @param options 设置选项
|
|
1507
|
+
*/
|
|
1508
|
+
export async function setFormControl(page, elementMap, options) {
|
|
1509
|
+
const { uid, value, trigger = 'change' } = options;
|
|
1510
|
+
if (!uid) {
|
|
1511
|
+
throw new Error("元素uid是必需的");
|
|
1512
|
+
}
|
|
1513
|
+
if (!page) {
|
|
1514
|
+
throw new Error("页面对象是必需的");
|
|
1515
|
+
}
|
|
1516
|
+
try {
|
|
1517
|
+
// 通过uid查找元素映射信息
|
|
1518
|
+
const mapInfo = elementMap.get(uid);
|
|
1519
|
+
if (!mapInfo) {
|
|
1520
|
+
throw new Error(`找不到uid为 ${uid} 的元素,请先获取页面快照`);
|
|
1521
|
+
}
|
|
1522
|
+
// 使用选择器获取所有匹配元素
|
|
1523
|
+
const elements = await page.$$(mapInfo.selector);
|
|
1524
|
+
if (!elements || elements.length === 0) {
|
|
1525
|
+
throw new Error(`无法找到选择器为 ${mapInfo.selector} 的元素`);
|
|
1526
|
+
}
|
|
1527
|
+
// 检查索引是否有效
|
|
1528
|
+
if (mapInfo.index >= elements.length) {
|
|
1529
|
+
throw new Error(`元素索引 ${mapInfo.index} 超出范围,共找到 ${elements.length} 个元素`);
|
|
1530
|
+
}
|
|
1531
|
+
// 通过索引获取目标元素
|
|
1532
|
+
const element = elements[mapInfo.index];
|
|
1533
|
+
if (!element) {
|
|
1534
|
+
throw new Error(`无法获取索引为 ${mapInfo.index} 的元素`);
|
|
1535
|
+
}
|
|
1536
|
+
// 设置值并触发事件
|
|
1537
|
+
await element.trigger(trigger, { value });
|
|
1538
|
+
}
|
|
1539
|
+
catch (error) {
|
|
1540
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1541
|
+
throw new Error(`设置表单控件失败: ${errorMessage}`);
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
/**
|
|
1545
|
+
* 断言元素存在性
|
|
1546
|
+
*
|
|
1547
|
+
* @param page 页面对象
|
|
1548
|
+
* @param options 断言选项
|
|
1549
|
+
* @returns 断言结果
|
|
1550
|
+
*/
|
|
1551
|
+
export async function assertElementExists(page, options) {
|
|
1552
|
+
const { selector, uid, timeout = 5000, shouldExist } = options;
|
|
1553
|
+
if (!selector && !uid) {
|
|
1554
|
+
throw new Error("必须提供selector或uid参数");
|
|
1555
|
+
}
|
|
1556
|
+
if (!page) {
|
|
1557
|
+
throw new Error("页面对象是必需的");
|
|
1558
|
+
}
|
|
1559
|
+
const startTime = Date.now();
|
|
1560
|
+
let element = null;
|
|
1561
|
+
let actualExists = false;
|
|
1562
|
+
try {
|
|
1563
|
+
// 在超时时间内检查元素存在性
|
|
1564
|
+
while (Date.now() - startTime < timeout) {
|
|
1565
|
+
try {
|
|
1566
|
+
if (selector) {
|
|
1567
|
+
element = await page.$(selector);
|
|
1568
|
+
}
|
|
1569
|
+
else if (uid) {
|
|
1570
|
+
// 如果只有uid,需要先从elementMap获取selector
|
|
1571
|
+
// 这里假设调用者已经有了正确的映射关系
|
|
1572
|
+
element = await page.$(uid);
|
|
1573
|
+
}
|
|
1574
|
+
actualExists = !!element;
|
|
1575
|
+
if (actualExists === shouldExist) {
|
|
1576
|
+
return {
|
|
1577
|
+
passed: true,
|
|
1578
|
+
message: `断言通过: 元素${shouldExist ? '存在' : '不存在'}`,
|
|
1579
|
+
actual: actualExists,
|
|
1580
|
+
expected: shouldExist,
|
|
1581
|
+
timestamp: Date.now()
|
|
1582
|
+
};
|
|
1583
|
+
}
|
|
1584
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
1585
|
+
}
|
|
1586
|
+
catch (error) {
|
|
1587
|
+
// 继续检查直到超时
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
// 超时后返回失败结果
|
|
1591
|
+
return {
|
|
1592
|
+
passed: false,
|
|
1593
|
+
message: `断言失败: 期望元素${shouldExist ? '存在' : '不存在'},实际${actualExists ? '存在' : '不存在'}`,
|
|
1594
|
+
actual: actualExists,
|
|
1595
|
+
expected: shouldExist,
|
|
1596
|
+
timestamp: Date.now()
|
|
1597
|
+
};
|
|
1598
|
+
}
|
|
1599
|
+
catch (error) {
|
|
1600
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1601
|
+
return {
|
|
1602
|
+
passed: false,
|
|
1603
|
+
message: `断言执行失败: ${errorMessage}`,
|
|
1604
|
+
actual: null,
|
|
1605
|
+
expected: shouldExist,
|
|
1606
|
+
timestamp: Date.now()
|
|
1607
|
+
};
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
/**
|
|
1611
|
+
* 断言元素可见性
|
|
1612
|
+
*
|
|
1613
|
+
* @param page 页面对象
|
|
1614
|
+
* @param elementMap 元素映射
|
|
1615
|
+
* @param options 断言选项
|
|
1616
|
+
* @returns 断言结果
|
|
1617
|
+
*/
|
|
1618
|
+
export async function assertElementVisible(page, elementMap, options) {
|
|
1619
|
+
const { uid, visible } = options;
|
|
1620
|
+
if (visible === undefined) {
|
|
1621
|
+
throw new Error("必须指定visible参数");
|
|
1622
|
+
}
|
|
1623
|
+
if (!uid) {
|
|
1624
|
+
throw new Error("元素uid是必需的");
|
|
1625
|
+
}
|
|
1626
|
+
if (!page) {
|
|
1627
|
+
throw new Error("页面对象是必需的");
|
|
1628
|
+
}
|
|
1629
|
+
try {
|
|
1630
|
+
// 通过uid查找元素映射信息
|
|
1631
|
+
const mapInfo = elementMap.get(uid);
|
|
1632
|
+
if (!mapInfo) {
|
|
1633
|
+
return {
|
|
1634
|
+
passed: false,
|
|
1635
|
+
message: `断言失败: 找不到uid为 ${uid} 的元素`,
|
|
1636
|
+
actual: null,
|
|
1637
|
+
expected: visible,
|
|
1638
|
+
timestamp: Date.now()
|
|
1639
|
+
};
|
|
1640
|
+
}
|
|
1641
|
+
// 使用选择器获取所有匹配元素
|
|
1642
|
+
const elements = await page.$$(mapInfo.selector);
|
|
1643
|
+
if (!elements || elements.length === 0) {
|
|
1644
|
+
return {
|
|
1645
|
+
passed: false,
|
|
1646
|
+
message: `断言失败: 无法找到选择器为 ${mapInfo.selector} 的元素`,
|
|
1647
|
+
actual: false,
|
|
1648
|
+
expected: visible,
|
|
1649
|
+
timestamp: Date.now()
|
|
1650
|
+
};
|
|
1651
|
+
}
|
|
1652
|
+
// 检查索引是否有效
|
|
1653
|
+
if (mapInfo.index >= elements.length) {
|
|
1654
|
+
return {
|
|
1655
|
+
passed: false,
|
|
1656
|
+
message: `断言失败: 元素索引 ${mapInfo.index} 超出范围,共找到 ${elements.length} 个元素`,
|
|
1657
|
+
actual: false,
|
|
1658
|
+
expected: visible,
|
|
1659
|
+
timestamp: Date.now()
|
|
1660
|
+
};
|
|
1661
|
+
}
|
|
1662
|
+
// 通过索引获取目标元素
|
|
1663
|
+
const element = elements[mapInfo.index];
|
|
1664
|
+
if (!element) {
|
|
1665
|
+
return {
|
|
1666
|
+
passed: false,
|
|
1667
|
+
message: `断言失败: 无法获取索引为 ${mapInfo.index} 的元素`,
|
|
1668
|
+
actual: false,
|
|
1669
|
+
expected: visible,
|
|
1670
|
+
timestamp: Date.now()
|
|
1671
|
+
};
|
|
1672
|
+
}
|
|
1673
|
+
// 检查可见性
|
|
1674
|
+
const size = await element.size();
|
|
1675
|
+
const actualVisible = size.width > 0 && size.height > 0;
|
|
1676
|
+
const passed = actualVisible === visible;
|
|
1677
|
+
return {
|
|
1678
|
+
passed,
|
|
1679
|
+
message: passed
|
|
1680
|
+
? `断言通过: 元素${visible ? '可见' : '不可见'}`
|
|
1681
|
+
: `断言失败: 期望元素${visible ? '可见' : '不可见'},实际${actualVisible ? '可见' : '不可见'}`,
|
|
1682
|
+
actual: actualVisible,
|
|
1683
|
+
expected: visible,
|
|
1684
|
+
timestamp: Date.now()
|
|
1685
|
+
};
|
|
1686
|
+
}
|
|
1687
|
+
catch (error) {
|
|
1688
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1689
|
+
return {
|
|
1690
|
+
passed: false,
|
|
1691
|
+
message: `断言执行失败: ${errorMessage}`,
|
|
1692
|
+
actual: null,
|
|
1693
|
+
expected: visible,
|
|
1694
|
+
timestamp: Date.now()
|
|
1695
|
+
};
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
/**
|
|
1699
|
+
* 断言元素文本内容
|
|
1700
|
+
*
|
|
1701
|
+
* @param page 页面对象
|
|
1702
|
+
* @param elementMap 元素映射
|
|
1703
|
+
* @param options 断言选项
|
|
1704
|
+
* @returns 断言结果
|
|
1705
|
+
*/
|
|
1706
|
+
export async function assertElementText(page, elementMap, options) {
|
|
1707
|
+
const { uid, text, textContains, textMatches } = options;
|
|
1708
|
+
if (!text && !textContains && !textMatches) {
|
|
1709
|
+
throw new Error("必须指定text、textContains或textMatches参数之一");
|
|
1710
|
+
}
|
|
1711
|
+
if (!uid) {
|
|
1712
|
+
throw new Error("元素uid是必需的");
|
|
1713
|
+
}
|
|
1714
|
+
if (!page) {
|
|
1715
|
+
throw new Error("页面对象是必需的");
|
|
1716
|
+
}
|
|
1717
|
+
try {
|
|
1718
|
+
// 通过uid查找元素映射信息
|
|
1719
|
+
const mapInfo = elementMap.get(uid);
|
|
1720
|
+
if (!mapInfo) {
|
|
1721
|
+
return {
|
|
1722
|
+
passed: false,
|
|
1723
|
+
message: `断言失败: 找不到uid为 ${uid} 的元素`,
|
|
1724
|
+
actual: null,
|
|
1725
|
+
expected: text || textContains || textMatches,
|
|
1726
|
+
timestamp: Date.now()
|
|
1727
|
+
};
|
|
1728
|
+
}
|
|
1729
|
+
// 使用选择器获取所有匹配元素
|
|
1730
|
+
const elements = await page.$$(mapInfo.selector);
|
|
1731
|
+
if (!elements || elements.length === 0) {
|
|
1732
|
+
return {
|
|
1733
|
+
passed: false,
|
|
1734
|
+
message: `断言失败: 无法找到选择器为 ${mapInfo.selector} 的元素`,
|
|
1735
|
+
actual: null,
|
|
1736
|
+
expected: text || textContains || textMatches,
|
|
1737
|
+
timestamp: Date.now()
|
|
1738
|
+
};
|
|
1739
|
+
}
|
|
1740
|
+
// 检查索引是否有效
|
|
1741
|
+
if (mapInfo.index >= elements.length) {
|
|
1742
|
+
return {
|
|
1743
|
+
passed: false,
|
|
1744
|
+
message: `断言失败: 元素索引 ${mapInfo.index} 超出范围,共找到 ${elements.length} 个元素`,
|
|
1745
|
+
actual: null,
|
|
1746
|
+
expected: text || textContains || textMatches,
|
|
1747
|
+
timestamp: Date.now()
|
|
1748
|
+
};
|
|
1749
|
+
}
|
|
1750
|
+
// 通过索引获取目标元素
|
|
1751
|
+
const element = elements[mapInfo.index];
|
|
1752
|
+
if (!element) {
|
|
1753
|
+
return {
|
|
1754
|
+
passed: false,
|
|
1755
|
+
message: `断言失败: 无法获取索引为 ${mapInfo.index} 的元素`,
|
|
1756
|
+
actual: null,
|
|
1757
|
+
expected: text || textContains || textMatches,
|
|
1758
|
+
timestamp: Date.now()
|
|
1759
|
+
};
|
|
1760
|
+
}
|
|
1761
|
+
// 获取元素文本
|
|
1762
|
+
const actualText = await element.text();
|
|
1763
|
+
let passed = false;
|
|
1764
|
+
let expectedValue = '';
|
|
1765
|
+
let message = '';
|
|
1766
|
+
if (text) {
|
|
1767
|
+
// 精确匹配
|
|
1768
|
+
passed = actualText === text;
|
|
1769
|
+
expectedValue = text;
|
|
1770
|
+
message = passed
|
|
1771
|
+
? `断言通过: 文本精确匹配`
|
|
1772
|
+
: `断言失败: 期望文本 "${text}",实际 "${actualText}"`;
|
|
1773
|
+
}
|
|
1774
|
+
else if (textContains) {
|
|
1775
|
+
// 包含匹配
|
|
1776
|
+
passed = actualText.includes(textContains);
|
|
1777
|
+
expectedValue = textContains;
|
|
1778
|
+
message = passed
|
|
1779
|
+
? `断言通过: 文本包含 "${textContains}"`
|
|
1780
|
+
: `断言失败: 期望包含 "${textContains}",实际文本 "${actualText}"`;
|
|
1781
|
+
}
|
|
1782
|
+
else if (textMatches) {
|
|
1783
|
+
// 正则匹配
|
|
1784
|
+
const regex = new RegExp(textMatches);
|
|
1785
|
+
passed = regex.test(actualText);
|
|
1786
|
+
expectedValue = textMatches;
|
|
1787
|
+
message = passed
|
|
1788
|
+
? `断言通过: 文本匹配正则 ${textMatches}`
|
|
1789
|
+
: `断言失败: 期望匹配正则 ${textMatches},实际文本 "${actualText}"`;
|
|
1790
|
+
}
|
|
1791
|
+
return {
|
|
1792
|
+
passed,
|
|
1793
|
+
message,
|
|
1794
|
+
actual: actualText,
|
|
1795
|
+
expected: expectedValue,
|
|
1796
|
+
timestamp: Date.now()
|
|
1797
|
+
};
|
|
1798
|
+
}
|
|
1799
|
+
catch (error) {
|
|
1800
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1801
|
+
return {
|
|
1802
|
+
passed: false,
|
|
1803
|
+
message: `断言执行失败: ${errorMessage}`,
|
|
1804
|
+
actual: null,
|
|
1805
|
+
expected: text || textContains || textMatches,
|
|
1806
|
+
timestamp: Date.now()
|
|
1807
|
+
};
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1810
|
+
/**
|
|
1811
|
+
* 断言元素属性
|
|
1812
|
+
*
|
|
1813
|
+
* @param page 页面对象
|
|
1814
|
+
* @param elementMap 元素映射
|
|
1815
|
+
* @param options 断言选项
|
|
1816
|
+
* @returns 断言结果
|
|
1817
|
+
*/
|
|
1818
|
+
export async function assertElementAttribute(page, elementMap, options) {
|
|
1819
|
+
const { uid, attribute } = options;
|
|
1820
|
+
if (!attribute) {
|
|
1821
|
+
throw new Error("必须指定attribute参数");
|
|
1822
|
+
}
|
|
1823
|
+
if (!uid) {
|
|
1824
|
+
throw new Error("元素uid是必需的");
|
|
1825
|
+
}
|
|
1826
|
+
if (!page) {
|
|
1827
|
+
throw new Error("页面对象是必需的");
|
|
1828
|
+
}
|
|
1829
|
+
try {
|
|
1830
|
+
// 通过uid查找元素映射信息
|
|
1831
|
+
const mapInfo = elementMap.get(uid);
|
|
1832
|
+
if (!mapInfo) {
|
|
1833
|
+
return {
|
|
1834
|
+
passed: false,
|
|
1835
|
+
message: `断言失败: 找不到uid为 ${uid} 的元素`,
|
|
1836
|
+
actual: null,
|
|
1837
|
+
expected: attribute.value,
|
|
1838
|
+
timestamp: Date.now()
|
|
1839
|
+
};
|
|
1840
|
+
}
|
|
1841
|
+
// 使用选择器获取所有匹配元素
|
|
1842
|
+
const elements = await page.$$(mapInfo.selector);
|
|
1843
|
+
if (!elements || elements.length === 0) {
|
|
1844
|
+
return {
|
|
1845
|
+
passed: false,
|
|
1846
|
+
message: `断言失败: 无法找到选择器为 ${mapInfo.selector} 的元素`,
|
|
1847
|
+
actual: null,
|
|
1848
|
+
expected: attribute.value,
|
|
1849
|
+
timestamp: Date.now()
|
|
1850
|
+
};
|
|
1851
|
+
}
|
|
1852
|
+
// 检查索引是否有效
|
|
1853
|
+
if (mapInfo.index >= elements.length) {
|
|
1854
|
+
return {
|
|
1855
|
+
passed: false,
|
|
1856
|
+
message: `断言失败: 元素索引 ${mapInfo.index} 超出范围,共找到 ${elements.length} 个元素`,
|
|
1857
|
+
actual: null,
|
|
1858
|
+
expected: attribute.value,
|
|
1859
|
+
timestamp: Date.now()
|
|
1860
|
+
};
|
|
1861
|
+
}
|
|
1862
|
+
// 通过索引获取目标元素
|
|
1863
|
+
const element = elements[mapInfo.index];
|
|
1864
|
+
if (!element) {
|
|
1865
|
+
return {
|
|
1866
|
+
passed: false,
|
|
1867
|
+
message: `断言失败: 无法获取索引为 ${mapInfo.index} 的元素`,
|
|
1868
|
+
actual: null,
|
|
1869
|
+
expected: attribute.value,
|
|
1870
|
+
timestamp: Date.now()
|
|
1871
|
+
};
|
|
1872
|
+
}
|
|
1873
|
+
// 获取属性值
|
|
1874
|
+
const actualValue = await element.attribute(attribute.key);
|
|
1875
|
+
const passed = actualValue === attribute.value;
|
|
1876
|
+
return {
|
|
1877
|
+
passed,
|
|
1878
|
+
message: passed
|
|
1879
|
+
? `断言通过: 属性 ${attribute.key} 值为 "${attribute.value}"`
|
|
1880
|
+
: `断言失败: 期望属性 ${attribute.key} 值为 "${attribute.value}",实际 "${actualValue}"`,
|
|
1881
|
+
actual: actualValue,
|
|
1882
|
+
expected: attribute.value,
|
|
1883
|
+
timestamp: Date.now()
|
|
1884
|
+
};
|
|
1885
|
+
}
|
|
1886
|
+
catch (error) {
|
|
1887
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1888
|
+
return {
|
|
1889
|
+
passed: false,
|
|
1890
|
+
message: `断言执行失败: ${errorMessage}`,
|
|
1891
|
+
actual: null,
|
|
1892
|
+
expected: attribute.value,
|
|
1893
|
+
timestamp: Date.now()
|
|
1894
|
+
};
|
|
1895
|
+
}
|
|
1896
|
+
}
|
|
1897
|
+
/**
|
|
1898
|
+
* 跳转到指定页面
|
|
1899
|
+
*
|
|
1900
|
+
* @param miniProgram MiniProgram对象
|
|
1901
|
+
* @param options 导航选项
|
|
1902
|
+
*/
|
|
1903
|
+
export async function navigateToPage(miniProgram, options) {
|
|
1904
|
+
const { url, params, waitForLoad = true, timeout = 10000 } = options;
|
|
1905
|
+
if (!url) {
|
|
1906
|
+
throw new Error("页面URL是必需的");
|
|
1907
|
+
}
|
|
1908
|
+
if (!miniProgram) {
|
|
1909
|
+
throw new Error("MiniProgram对象是必需的");
|
|
1910
|
+
}
|
|
1911
|
+
try {
|
|
1912
|
+
// 构建完整的URL
|
|
1913
|
+
let fullUrl = url;
|
|
1914
|
+
if (params && Object.keys(params).length > 0) {
|
|
1915
|
+
const queryString = Object.entries(params)
|
|
1916
|
+
.map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
|
|
1917
|
+
.join('&');
|
|
1918
|
+
fullUrl += (url.includes('?') ? '&' : '?') + queryString;
|
|
1919
|
+
}
|
|
1920
|
+
// 执行页面跳转
|
|
1921
|
+
await miniProgram.navigateTo(fullUrl);
|
|
1922
|
+
// 等待页面加载完成
|
|
1923
|
+
if (waitForLoad) {
|
|
1924
|
+
const startTime = Date.now();
|
|
1925
|
+
while (Date.now() - startTime < timeout) {
|
|
1926
|
+
try {
|
|
1927
|
+
const currentPage = await miniProgram.currentPage();
|
|
1928
|
+
if (currentPage) {
|
|
1929
|
+
const currentPath = await currentPage.path;
|
|
1930
|
+
// 检查是否已经跳转到目标页面
|
|
1931
|
+
if (currentPath.includes(url.split('?')[0])) {
|
|
1932
|
+
break;
|
|
1933
|
+
}
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
catch (error) {
|
|
1937
|
+
// 继续等待
|
|
1938
|
+
}
|
|
1939
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
1940
|
+
}
|
|
1941
|
+
}
|
|
1942
|
+
}
|
|
1943
|
+
catch (error) {
|
|
1944
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1945
|
+
throw new Error(`页面跳转失败: ${errorMessage}`);
|
|
1946
|
+
}
|
|
1947
|
+
}
|
|
1948
|
+
/**
|
|
1949
|
+
* 返回上一页
|
|
1950
|
+
*
|
|
1951
|
+
* @param miniProgram MiniProgram对象
|
|
1952
|
+
* @param options 返回选项
|
|
1953
|
+
*/
|
|
1954
|
+
export async function navigateBack(miniProgram, options = {}) {
|
|
1955
|
+
const { delta = 1, waitForLoad = true, timeout = 5000 } = options;
|
|
1956
|
+
if (!miniProgram) {
|
|
1957
|
+
throw new Error("MiniProgram对象是必需的");
|
|
1958
|
+
}
|
|
1959
|
+
try {
|
|
1960
|
+
// 获取当前页面路径(用于验证是否成功返回)
|
|
1961
|
+
let currentPath = '';
|
|
1962
|
+
try {
|
|
1963
|
+
const currentPage = await miniProgram.currentPage();
|
|
1964
|
+
currentPath = await currentPage.path;
|
|
1965
|
+
}
|
|
1966
|
+
catch (error) {
|
|
1967
|
+
// 忽略获取当前路径的错误
|
|
1968
|
+
}
|
|
1969
|
+
// 执行返回操作
|
|
1970
|
+
await miniProgram.navigateBack(delta);
|
|
1971
|
+
// 等待页面加载完成
|
|
1972
|
+
if (waitForLoad) {
|
|
1973
|
+
const startTime = Date.now();
|
|
1974
|
+
while (Date.now() - startTime < timeout) {
|
|
1975
|
+
try {
|
|
1976
|
+
const newPage = await miniProgram.currentPage();
|
|
1977
|
+
if (newPage) {
|
|
1978
|
+
const newPath = await newPage.path;
|
|
1979
|
+
// 检查是否已经成功返回(路径发生变化)
|
|
1980
|
+
if (newPath !== currentPath) {
|
|
1981
|
+
break;
|
|
1982
|
+
}
|
|
1983
|
+
}
|
|
1984
|
+
}
|
|
1985
|
+
catch (error) {
|
|
1986
|
+
// 继续等待
|
|
1987
|
+
}
|
|
1988
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
1989
|
+
}
|
|
1990
|
+
}
|
|
1991
|
+
}
|
|
1992
|
+
catch (error) {
|
|
1993
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1994
|
+
throw new Error(`页面返回失败: ${errorMessage}`);
|
|
1995
|
+
}
|
|
1996
|
+
}
|
|
1997
|
+
/**
|
|
1998
|
+
* 切换到Tab页
|
|
1999
|
+
*
|
|
2000
|
+
* @param miniProgram MiniProgram对象
|
|
2001
|
+
* @param options Tab切换选项
|
|
2002
|
+
*/
|
|
2003
|
+
export async function switchTab(miniProgram, options) {
|
|
2004
|
+
const { url, waitForLoad = true, timeout = 5000 } = options;
|
|
2005
|
+
if (!url) {
|
|
2006
|
+
throw new Error("Tab页URL是必需的");
|
|
2007
|
+
}
|
|
2008
|
+
if (!miniProgram) {
|
|
2009
|
+
throw new Error("MiniProgram对象是必需的");
|
|
2010
|
+
}
|
|
2011
|
+
try {
|
|
2012
|
+
// 执行Tab切换
|
|
2013
|
+
await miniProgram.switchTab(url);
|
|
2014
|
+
// 等待页面加载完成
|
|
2015
|
+
if (waitForLoad) {
|
|
2016
|
+
const startTime = Date.now();
|
|
2017
|
+
while (Date.now() - startTime < timeout) {
|
|
2018
|
+
try {
|
|
2019
|
+
const currentPage = await miniProgram.currentPage();
|
|
2020
|
+
if (currentPage) {
|
|
2021
|
+
const currentPath = await currentPage.path;
|
|
2022
|
+
// 检查是否已经切换到目标Tab页
|
|
2023
|
+
if (currentPath.includes(url.split('?')[0])) {
|
|
2024
|
+
break;
|
|
2025
|
+
}
|
|
2026
|
+
}
|
|
2027
|
+
}
|
|
2028
|
+
catch (error) {
|
|
2029
|
+
// 继续等待
|
|
2030
|
+
}
|
|
2031
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
2032
|
+
}
|
|
2033
|
+
}
|
|
2034
|
+
}
|
|
2035
|
+
catch (error) {
|
|
2036
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
2037
|
+
throw new Error(`Tab切换失败: ${errorMessage}`);
|
|
2038
|
+
}
|
|
2039
|
+
}
|
|
2040
|
+
/**
|
|
2041
|
+
* 获取当前页面信息
|
|
2042
|
+
*
|
|
2043
|
+
* @param miniProgram MiniProgram对象
|
|
2044
|
+
* @returns 页面信息
|
|
2045
|
+
*/
|
|
2046
|
+
export async function getCurrentPageInfo(miniProgram) {
|
|
2047
|
+
if (!miniProgram) {
|
|
2048
|
+
throw new Error("MiniProgram对象是必需的");
|
|
2049
|
+
}
|
|
2050
|
+
try {
|
|
2051
|
+
const currentPage = await miniProgram.currentPage();
|
|
2052
|
+
if (!currentPage) {
|
|
2053
|
+
throw new Error("无法获取当前页面");
|
|
2054
|
+
}
|
|
2055
|
+
const path = await currentPage.path;
|
|
2056
|
+
// 尝试获取页面标题和查询参数
|
|
2057
|
+
let title;
|
|
2058
|
+
let query;
|
|
2059
|
+
try {
|
|
2060
|
+
// 获取页面数据(如果可用)
|
|
2061
|
+
const data = await currentPage.data();
|
|
2062
|
+
if (data) {
|
|
2063
|
+
title = data.title || data.navigationBarTitleText;
|
|
2064
|
+
query = data.query || data.options;
|
|
2065
|
+
}
|
|
2066
|
+
}
|
|
2067
|
+
catch (error) {
|
|
2068
|
+
// 如果无法获取页面数据,忽略错误
|
|
2069
|
+
}
|
|
2070
|
+
return {
|
|
2071
|
+
path,
|
|
2072
|
+
title,
|
|
2073
|
+
query
|
|
2074
|
+
};
|
|
2075
|
+
}
|
|
2076
|
+
catch (error) {
|
|
2077
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
2078
|
+
throw new Error(`获取页面信息失败: ${errorMessage}`);
|
|
2079
|
+
}
|
|
2080
|
+
}
|
|
2081
|
+
/**
|
|
2082
|
+
* 重新启动到指定页面
|
|
2083
|
+
*
|
|
2084
|
+
* @param miniProgram MiniProgram对象
|
|
2085
|
+
* @param options 导航选项
|
|
2086
|
+
*/
|
|
2087
|
+
export async function reLaunch(miniProgram, options) {
|
|
2088
|
+
const { url, params, waitForLoad = true, timeout = 10000 } = options;
|
|
2089
|
+
if (!url) {
|
|
2090
|
+
throw new Error("页面URL是必需的");
|
|
2091
|
+
}
|
|
2092
|
+
if (!miniProgram) {
|
|
2093
|
+
throw new Error("MiniProgram对象是必需的");
|
|
2094
|
+
}
|
|
2095
|
+
try {
|
|
2096
|
+
// 构建完整的URL
|
|
2097
|
+
let fullUrl = url;
|
|
2098
|
+
if (params && Object.keys(params).length > 0) {
|
|
2099
|
+
const queryString = Object.entries(params)
|
|
2100
|
+
.map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
|
|
2101
|
+
.join('&');
|
|
2102
|
+
fullUrl += (url.includes('?') ? '&' : '?') + queryString;
|
|
2103
|
+
}
|
|
2104
|
+
// 执行重新启动
|
|
2105
|
+
await miniProgram.reLaunch(fullUrl);
|
|
2106
|
+
// 等待页面加载完成
|
|
2107
|
+
if (waitForLoad) {
|
|
2108
|
+
const startTime = Date.now();
|
|
2109
|
+
while (Date.now() - startTime < timeout) {
|
|
2110
|
+
try {
|
|
2111
|
+
const currentPage = await miniProgram.currentPage();
|
|
2112
|
+
if (currentPage) {
|
|
2113
|
+
const currentPath = await currentPage.path;
|
|
2114
|
+
// 检查是否已经重新启动到目标页面
|
|
2115
|
+
if (currentPath.includes(url.split('?')[0])) {
|
|
2116
|
+
break;
|
|
2117
|
+
}
|
|
2118
|
+
}
|
|
2119
|
+
}
|
|
2120
|
+
catch (error) {
|
|
2121
|
+
// 继续等待
|
|
2122
|
+
}
|
|
2123
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
2124
|
+
}
|
|
2125
|
+
}
|
|
2126
|
+
}
|
|
2127
|
+
catch (error) {
|
|
2128
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
2129
|
+
throw new Error(`重新启动失败: ${errorMessage}`);
|
|
2130
|
+
}
|
|
2131
|
+
}
|