android-midscene-automation 0.1.31 → 0.1.33
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/CHANGELOG.md +14 -1
- package/README.md +15 -1
- package/bin/sync-readme-docs.mjs +49 -0
- package/package.json +8 -8
- package/remote-agent/index.ts +6 -2
- package/server/appium-recorder/appium-runner.ts +111 -30
- package/server/appium-recorder/history-capture.ts +34 -0
- package/server/appium-recorder/report.ts +1 -1
- package/server/appium-recorder/repository.ts +32 -1
- package/server/appium-recorder/routes.ts +49 -6
- package/server/appium-recorder/run-history.ts +34 -0
- package/server/appium-recorder/script-export.ts +11 -3
- package/server/appium-recorder/variable-store.ts +22 -0
- package/server/appium-recorder/variables.ts +89 -0
- package/src/appium-recorder/AppiumPage.vue +91 -34
- package/src/appium-recorder/RunHistoryDialog.vue +153 -0
- package/src/appium-recorder/api.ts +23 -2
- package/src/appium-recorder/components/FlowStepEditor.vue +28 -2
- package/src/appium-recorder/components/NestedConditionBranches.vue +5 -4
- package/src/appium-recorder/components/PresetVariables.vue +109 -0
- package/src/appium-recorder/components/RecordedSteps.vue +3 -6
- package/src/appium-recorder/components/ScriptParameterSettings.vue +31 -0
- package/src/appium-recorder/components/VariableExtractionSettings.vue +17 -0
- package/src/appium-recorder/components/VariableTable.vue +43 -0
- package/src/appium-recorder/components/VisualChangeEndPicker.vue +21 -0
- package/src/appium-recorder/flow-graph.ts +2 -8
- package/src/appium-recorder/flow-labels.ts +4 -1
- package/src/appium-recorder/run-history.ts +50 -0
- package/src/appium-recorder/types.ts +7 -0
- package/src/appium-recorder/variables.ts +34 -0
- package/src/appium-recorder/visual-change-pairs.ts +16 -10
package/CHANGELOG.md
CHANGED
|
@@ -1,10 +1,23 @@
|
|
|
1
1
|
# 更新记录
|
|
2
|
+
## v0.1.33
|
|
3
|
+
- 操作菜单移除“断言存在”和“断言文本”,统一保留“判断存在”及其文本匹配配置;旧脚本中的断言节点仍兼容原有执行行为。
|
|
4
|
+
- “点击坐标”节点支持编辑 X、Y 坐标,保存后回放使用新坐标;默认节点名称同步更新,自定义名称保持不变。
|
|
5
|
+
- 新增“预设变量”区域,支持全局变量和脚本私有变量,输入、判断、定位和日志支持 `{{变量名}}` 引用。
|
|
6
|
+
|
|
7
|
+
## v0.1.32
|
|
8
|
+
- 添加画面变化结束节点时,多组可配对检测支持按名称及开始节点编号选择,允许交叉结束;只有一组时直接添加,保留分支及重复结束校验。
|
|
9
|
+
- 画面变化结束节点校验覆盖祖先及子分支:同一执行路径已存在对应结束节点时,后续分支不能重复添加;互斥分支仍可分别结束。
|
|
10
|
+
- 脚本列表新增“历史结果对比”:保存运行结果、应用版本、设备、截图和日志,支持筛选查看通过率、耗时趋势与失败节点分布,对比两次运行并删除选中历史;手动终止不计入通过率,旧报告不自动补录。
|
|
11
|
+
- 画面变化检测在每个结束节点执行后立即对比本次截图,避免循环及重复调用脚本时多组检测复用最后一轮图片;截图缺失时明确报告异常,不使用旧图替代。
|
|
12
|
+
- “启动 APP”支持在流程中途、判断分支及循环体内多次添加,按所选位置执行;连接脚本仅跳过开头的应用初始化,中途启动操作正常保留。
|
|
13
|
+
- Midscene 相关模块统一从 1.12.2 升级至 1.12.5。
|
|
14
|
+
|
|
2
15
|
|
|
3
16
|
## v0.1.31
|
|
4
17
|
- 组件详情展示 Resource ID、Accessibility ID、XPath 和 UiAutomator 四种定位内容,支持分别复制,方便配置循环退出条件;缺少对应属性时显示未提供。
|
|
5
18
|
- “流程控制”新增有界循环及退出循环:支持固定次数、元素出现或消失时退出,必须设置最大执行次数;循环体支持嵌套判断、合流和连接脚本。退出节点可选择当前或外层循环,退出外层时同时结束内部循环并直接执行目标循环结束路径,日志及 HTML 报告标注轮次和退出目标。
|
|
6
19
|
- 新增流程节点搜索,支持按名称、操作类型、定位内容和日志关键字筛选,展示所属分支并在多个结果间切换;普通画布与流程总览支持定位高亮,不改变节点布局及批量选中状态。
|
|
7
|
-
- Midscene 相关模块统一从 1.12.0 升级至 1.12.2
|
|
20
|
+
- Midscene 相关模块统一从 1.12.0 升级至 1.12.2。
|
|
8
21
|
- 录制工作区支持拖拽调整设备预览、组件树栏宽,自动保存布局并支持恢复默认。
|
|
9
22
|
|
|
10
23
|
## v0.1.30
|
package/README.md
CHANGED
|
@@ -1229,11 +1229,25 @@ Linux: ~/.local/share/android-midscene-automation
|
|
|
1229
1229
|
|
|
1230
1230
|
# 项目更新记录
|
|
1231
1231
|
|
|
1232
|
+
## v0.1.33
|
|
1233
|
+
- 操作菜单移除“断言存在”和“断言文本”,统一保留“判断存在”及其文本匹配配置;旧脚本中的断言节点仍兼容原有执行行为。
|
|
1234
|
+
- “点击坐标”节点支持编辑 X、Y 坐标,保存后回放使用新坐标;默认节点名称同步更新,自定义名称保持不变。
|
|
1235
|
+
- 新增“预设变量”区域,支持全局变量和脚本私有变量,输入、判断、定位和日志支持 `{{变量名}}` 引用。
|
|
1236
|
+
|
|
1237
|
+
## v0.1.32
|
|
1238
|
+
- 添加画面变化结束节点时,多组可配对检测支持按名称及开始节点编号选择,允许交叉结束;只有一组时直接添加,保留分支及重复结束校验。
|
|
1239
|
+
- 画面变化结束节点校验覆盖祖先及子分支:同一执行路径已存在对应结束节点时,后续分支不能重复添加;互斥分支仍可分别结束。
|
|
1240
|
+
- 脚本列表新增“历史结果对比”:保存运行结果、应用版本、设备、截图和日志,支持筛选查看通过率、耗时趋势与失败节点分布,对比两次运行并删除选中历史;手动终止不计入通过率,旧报告不自动补录。
|
|
1241
|
+
- 画面变化检测在每个结束节点执行后立即对比本次截图,避免循环及重复调用脚本时多组检测复用最后一轮图片;截图缺失时明确报告异常,不使用旧图替代。
|
|
1242
|
+
- “启动 APP”支持在流程中途、判断分支及循环体内多次添加,按所选位置执行;连接脚本仅跳过开头的应用初始化,中途启动操作正常保留。
|
|
1243
|
+
- Midscene 相关模块统一从 1.12.2 升级至 1.12.5。
|
|
1244
|
+
|
|
1245
|
+
|
|
1232
1246
|
## v0.1.31
|
|
1233
1247
|
- 组件详情展示 Resource ID、Accessibility ID、XPath 和 UiAutomator 四种定位内容,支持分别复制,方便配置循环退出条件;缺少对应属性时显示未提供。
|
|
1234
1248
|
- “流程控制”新增有界循环及退出循环:支持固定次数、元素出现或消失时退出,必须设置最大执行次数;循环体支持嵌套判断、合流和连接脚本。退出节点可选择当前或外层循环,退出外层时同时结束内部循环并直接执行目标循环结束路径,日志及 HTML 报告标注轮次和退出目标。
|
|
1235
1249
|
- 新增流程节点搜索,支持按名称、操作类型、定位内容和日志关键字筛选,展示所属分支并在多个结果间切换;普通画布与流程总览支持定位高亮,不改变节点布局及批量选中状态。
|
|
1236
|
-
- Midscene 相关模块统一从 1.12.0 升级至 1.12.2
|
|
1250
|
+
- Midscene 相关模块统一从 1.12.0 升级至 1.12.2。
|
|
1237
1251
|
- 录制工作区支持拖拽调整设备预览、组件树栏宽,自动保存布局并支持恢复默认。
|
|
1238
1252
|
|
|
1239
1253
|
## v0.1.30
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
|
|
3
|
+
const generatedStart = '<!-- generated-docs:start -->';
|
|
4
|
+
const generatedEnd = '<!-- generated-docs:end -->';
|
|
5
|
+
const readmeUrl = new URL('../README.md', import.meta.url);
|
|
6
|
+
|
|
7
|
+
const stripFirstHeading = (markdown) =>
|
|
8
|
+
markdown.replace(/^#\s+[^\r\n]+\r?\n+/, '').trim();
|
|
9
|
+
|
|
10
|
+
const stripUsageFaqPointer = (markdown) =>
|
|
11
|
+
markdown.replace(
|
|
12
|
+
/\n## 常见问题\r?\n\r?\n常见问题已独立整理到 \[FAQ\.md\]\(\.\/FAQ\.md\)。\r?\n/g,
|
|
13
|
+
'\n',
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
const linkReadmeAnchors = (markdown) =>
|
|
17
|
+
markdown.replace(/\[FAQ\.md\]\(\.\/FAQ\.md\)/g, '[常见问题](#常见问题)');
|
|
18
|
+
|
|
19
|
+
const [readme, usage, faq, changelog] = await Promise.all([
|
|
20
|
+
readFile(readmeUrl, 'utf8'),
|
|
21
|
+
readFile(new URL('../USAGE.md', import.meta.url), 'utf8'),
|
|
22
|
+
readFile(new URL('../FAQ.md', import.meta.url), 'utf8'),
|
|
23
|
+
readFile(new URL('../CHANGELOG.md', import.meta.url), 'utf8'),
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
const generatedIndex = readme.indexOf(generatedStart);
|
|
27
|
+
const readmeBody = (
|
|
28
|
+
generatedIndex >= 0 ? readme.slice(0, generatedIndex) : readme
|
|
29
|
+
).trimEnd();
|
|
30
|
+
|
|
31
|
+
const generatedDocs = [
|
|
32
|
+
generatedStart,
|
|
33
|
+
'',
|
|
34
|
+
'# Appium 录制器使用说明',
|
|
35
|
+
'',
|
|
36
|
+
linkReadmeAnchors(stripUsageFaqPointer(stripFirstHeading(usage))),
|
|
37
|
+
'',
|
|
38
|
+
'# 常见问题',
|
|
39
|
+
'',
|
|
40
|
+
stripFirstHeading(faq),
|
|
41
|
+
'',
|
|
42
|
+
'# 项目更新记录',
|
|
43
|
+
'',
|
|
44
|
+
stripFirstHeading(changelog),
|
|
45
|
+
'',
|
|
46
|
+
generatedEnd,
|
|
47
|
+
].join('\n');
|
|
48
|
+
|
|
49
|
+
await writeFile(readmeUrl, `${readmeBody}\n\n${generatedDocs}\n`, 'utf8');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "android-midscene-automation",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.33",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/oooooooko/android-midscene-automation.git"
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
],
|
|
29
29
|
"scripts": {
|
|
30
30
|
"dev": "vite --host 127.0.0.1",
|
|
31
|
-
"prepack": "node
|
|
31
|
+
"prepack": "node bin/sync-readme-docs.mjs",
|
|
32
32
|
"remote-agent": "tsx remote-agent/index.ts",
|
|
33
33
|
"build": "vue-tsc -b && vite build",
|
|
34
34
|
"preview": "vite preview --host 127.0.0.1",
|
|
@@ -38,12 +38,12 @@
|
|
|
38
38
|
"dependencies": {
|
|
39
39
|
"@dagrejs/dagre": "1.1.5",
|
|
40
40
|
"@element-plus/icons-vue": "2.3.2",
|
|
41
|
-
"@midscene/android": "https://registry.npmjs.org/@midscene/android/-/android-1.12.
|
|
42
|
-
"@midscene/android-playground": "https://registry.npmjs.org/@midscene/android-playground/-/android-playground-1.12.
|
|
43
|
-
"@midscene/core": "https://registry.npmjs.org/@midscene/core/-/core-1.12.
|
|
44
|
-
"@midscene/playground": "https://registry.npmjs.org/@midscene/playground/-/playground-1.12.
|
|
45
|
-
"@midscene/shared": "https://registry.npmjs.org/@midscene/shared/-/shared-1.12.
|
|
46
|
-
"@midscene/web": "https://registry.npmjs.org/@midscene/web/-/web-1.12.
|
|
41
|
+
"@midscene/android": "https://registry.npmjs.org/@midscene/android/-/android-1.12.5.tgz",
|
|
42
|
+
"@midscene/android-playground": "https://registry.npmjs.org/@midscene/android-playground/-/android-playground-1.12.5.tgz",
|
|
43
|
+
"@midscene/core": "https://registry.npmjs.org/@midscene/core/-/core-1.12.5.tgz",
|
|
44
|
+
"@midscene/playground": "https://registry.npmjs.org/@midscene/playground/-/playground-1.12.5.tgz",
|
|
45
|
+
"@midscene/shared": "https://registry.npmjs.org/@midscene/shared/-/shared-1.12.5.tgz",
|
|
46
|
+
"@midscene/web": "https://registry.npmjs.org/@midscene/web/-/web-1.12.5.tgz",
|
|
47
47
|
"@vitejs/plugin-vue": "6.0.7",
|
|
48
48
|
"@vue-flow/core": "1.48.2",
|
|
49
49
|
"archiver": "7.0.1",
|
package/remote-agent/index.ts
CHANGED
|
@@ -18,7 +18,7 @@ const agentName = argValue('agent-name', agentId);
|
|
|
18
18
|
const token = argValue('token', process.env.REMOTE_AGENT_TOKEN || '');
|
|
19
19
|
|
|
20
20
|
if (!server) {
|
|
21
|
-
console.error('缺少 --server,例如:npm run remote-agent -- --server http
|
|
21
|
+
console.error('缺少 --server,例如:npm run remote-agent -- --server http://<控制台主机地址>:5173');
|
|
22
22
|
process.exit(1);
|
|
23
23
|
}
|
|
24
24
|
|
|
@@ -133,7 +133,11 @@ async function handleCommand(command: RemoteCommand) {
|
|
|
133
133
|
if (command.type === 'replay') {
|
|
134
134
|
const script = payload.script as AppiumRecordedScriptRecord | undefined;
|
|
135
135
|
if (!script) throw new Error('远程回放缺少脚本内容');
|
|
136
|
-
return await replayAppiumScript(script, command.deviceId
|
|
136
|
+
return await replayAppiumScript(script, command.deviceId, undefined, undefined, {
|
|
137
|
+
parameters: payload.parameters as import('../src/appium-recorder/variables').TestVariable[] | undefined,
|
|
138
|
+
globalVariables: payload.globalVariables as import('../src/appium-recorder/variables').TestVariable[] | undefined,
|
|
139
|
+
linkedScripts: payload.linkedScripts as AppiumRecordedScriptRecord[] | undefined,
|
|
140
|
+
});
|
|
137
141
|
}
|
|
138
142
|
throw new Error(`不支持的命令:${command.type}`);
|
|
139
143
|
}
|
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
|
+
import { VariableScope, variableContext, resolveVariableStep } from './variables';
|
|
3
|
+
import { getPresetVariables } from './variable-store';
|
|
4
|
+
import { validateExtraction, validateVariables, type TestVariable } from '../../src/appium-recorder/variables';
|
|
5
|
+
import { captureHistoryFrames, readAppVersion } from './history-capture';
|
|
2
6
|
import { BoundedLoopTraversal } from './bounded-loop';
|
|
3
7
|
import { validateLoopSteps } from '../../src/appium-recorder/bounded-loop';
|
|
4
8
|
import { adbScreenshotBase64 } from './screenshot';
|
|
@@ -9,7 +13,7 @@ import { textClickSelector } from '../../src/appium-recorder/text-click';
|
|
|
9
13
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
10
14
|
import { mkdir, writeFile } from 'node:fs/promises';
|
|
11
15
|
import { join } from 'node:path';
|
|
12
|
-
import {
|
|
16
|
+
import { linkedScriptSnapshot, type AppiumRecordedScriptRecord, type AppiumRecordedStepRecord } from './repository';
|
|
13
17
|
import { appDataPath } from '../paths';
|
|
14
18
|
import { ensureAndroidSdkAvailable, getAdbCommand } from '../android-sdk';
|
|
15
19
|
import { createAppiumReplayReport, type AppiumReplayFrame, type AppiumReplayVisualCheck } from './report';
|
|
@@ -49,6 +53,8 @@ type PendingVisualChangeCheck = {
|
|
|
49
53
|
step: AppiumRecordedStepRecord;
|
|
50
54
|
nodeNumber: number;
|
|
51
55
|
scriptName: string;
|
|
56
|
+
frameStart: number;
|
|
57
|
+
endFrameStart: number;
|
|
52
58
|
};
|
|
53
59
|
|
|
54
60
|
const replayContext = new AsyncLocalStorage<{
|
|
@@ -56,9 +62,12 @@ const replayContext = new AsyncLocalStorage<{
|
|
|
56
62
|
appiumLog: (line: string) => void;
|
|
57
63
|
serverUrl: string;
|
|
58
64
|
deviceId: string;
|
|
65
|
+
scripts: Map<string, AppiumRecordedScriptRecord>;
|
|
59
66
|
frames: AppiumReplayFrame[];
|
|
67
|
+
historyEvents: AppiumReplayFrame[];
|
|
60
68
|
visualChecks: AppiumReplayVisualCheck[];
|
|
61
69
|
pendingVisualChecks: PendingVisualChangeCheck[];
|
|
70
|
+
visualStartOffsets: Map<string, number>;
|
|
62
71
|
softFailureCount: number;
|
|
63
72
|
flowEnded?: boolean;
|
|
64
73
|
}>();
|
|
@@ -332,6 +341,10 @@ async function captureReplayFrame(
|
|
|
332
341
|
) {
|
|
333
342
|
const context = replayContext.getStore();
|
|
334
343
|
if (!context || !context.deviceId) return;
|
|
344
|
+
// 执行记录独立于截图,设备截图失败不能让失败节点从统计中消失。
|
|
345
|
+
context.historyEvents.push({ sequence: context.historyEvents.length + 1, scriptName, nodeId: step.id,
|
|
346
|
+
nodeNumber, nodeLabel: step.label, nodeType: step.type, note: step.note || '',
|
|
347
|
+
selector: replayFrameSelector(step), phase, status, capturedAt: new Date().toISOString(), imageBase64: '' });
|
|
335
348
|
const appendFrame = (imageBase64: string) => {
|
|
336
349
|
context.frames.push({
|
|
337
350
|
sequence: context.frames.length + 1,
|
|
@@ -340,7 +353,7 @@ async function captureReplayFrame(
|
|
|
340
353
|
nodeNumber,
|
|
341
354
|
nodeLabel: step.label,
|
|
342
355
|
nodeType: step.type,
|
|
343
|
-
logContent: step.type === 'log' && phase === 'after' ? formatStageLog(step) : undefined,
|
|
356
|
+
logContent: step.type === 'log' && phase === 'after' ? formatStageLog(resolveVariableStep(step)) : undefined,
|
|
344
357
|
note: step.note || '',
|
|
345
358
|
selector: replayFrameSelector(step),
|
|
346
359
|
phase,
|
|
@@ -510,6 +523,7 @@ async function waitForElementGone(sessionId: string, step: AppiumRecordedStepRec
|
|
|
510
523
|
}
|
|
511
524
|
|
|
512
525
|
async function saveScreenshot(deviceId: string) {
|
|
526
|
+
if (variableContext.getStore()?.privacy.enabled) return;
|
|
513
527
|
const dir = appDataPath('.midscene-app', 'screenshots');
|
|
514
528
|
await mkdir(dir, { recursive: true });
|
|
515
529
|
const file = join(dir, `appium-${Date.now()}.png`);
|
|
@@ -519,6 +533,7 @@ async function saveScreenshot(deviceId: string) {
|
|
|
519
533
|
type RunStepMeta = {
|
|
520
534
|
nodeNumber: number;
|
|
521
535
|
scriptName: string;
|
|
536
|
+
frameStart?: number;
|
|
522
537
|
};
|
|
523
538
|
|
|
524
539
|
type RunStepResult = string | {
|
|
@@ -560,10 +575,9 @@ function findVisualFrame(
|
|
|
560
575
|
) {
|
|
561
576
|
const matches = frames.filter((frame) => frame.nodeId === nodeId && frame.scriptName === scriptName);
|
|
562
577
|
if (!matches.length) return undefined;
|
|
563
|
-
const
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
return preferred || (preferredPhase === 'before' ? matches[0] : matches[matches.length - 1]);
|
|
578
|
+
const frame = preferredPhase === 'before' ? matches[0] : matches[matches.length - 1];
|
|
579
|
+
// A failed capture must not fall back to an older round or a before/error image.
|
|
580
|
+
return frame?.phase === preferredPhase ? frame : undefined;
|
|
567
581
|
}
|
|
568
582
|
|
|
569
583
|
const EMPTY_PNG_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=';
|
|
@@ -574,12 +588,14 @@ function registerVisualChangeStep(
|
|
|
574
588
|
): RunStepResult {
|
|
575
589
|
const config = step.visualChange;
|
|
576
590
|
if (!config) throw new Error(`${step.label} 缺少画面变化检测配置`);
|
|
591
|
+
const context = replayContext.getStore();
|
|
577
592
|
if (config.role === 'start') {
|
|
593
|
+
// Invalidate the previous capture even when this start's after screenshot fails.
|
|
594
|
+
context?.visualStartOffsets.set(JSON.stringify([meta.frameStart || 0, step.id]), context.frames.length);
|
|
578
595
|
return {
|
|
579
596
|
message: `已记录画面变化基准点:${config.pairLabel || step.label}`,
|
|
580
597
|
};
|
|
581
598
|
}
|
|
582
|
-
const context = replayContext.getStore();
|
|
583
599
|
const endStep = {
|
|
584
600
|
...step,
|
|
585
601
|
visualChange: {
|
|
@@ -587,7 +603,9 @@ function registerVisualChangeStep(
|
|
|
587
603
|
endStepId: config.endStepId || step.id,
|
|
588
604
|
},
|
|
589
605
|
};
|
|
590
|
-
context?.pendingVisualChecks.push({ step: endStep, nodeNumber: meta.nodeNumber, scriptName: meta.scriptName
|
|
606
|
+
context?.pendingVisualChecks.push({ step: endStep, nodeNumber: meta.nodeNumber, scriptName: meta.scriptName,
|
|
607
|
+
frameStart: context.visualStartOffsets.get(JSON.stringify([meta.frameStart || 0, config.startStepId])) ?? meta.frameStart ?? 0,
|
|
608
|
+
endFrameStart: context.frames.length });
|
|
591
609
|
return {
|
|
592
610
|
message: `已记录画面变化对比点:${config.pairLabel || step.label}`,
|
|
593
611
|
};
|
|
@@ -624,16 +642,16 @@ function processPendingVisualChecks(lines: string[]) {
|
|
|
624
642
|
const context = replayContext.getStore();
|
|
625
643
|
if (!context || !context.pendingVisualChecks.length) return;
|
|
626
644
|
|
|
627
|
-
//
|
|
628
|
-
//
|
|
645
|
+
// Resolve each pair immediately after its end capture, before later rounds
|
|
646
|
+
// or linked-script calls can replace the images associated with these IDs.
|
|
629
647
|
context.pendingVisualChecks.forEach((check) => {
|
|
630
648
|
const config = check.step.visualChange;
|
|
631
649
|
if (!config?.startStepId || !config.endStepId) {
|
|
632
650
|
appendVisualCheckFailure(lines, check, `${check.step.label} 缺少开始节点或结束节点`);
|
|
633
651
|
return;
|
|
634
652
|
}
|
|
635
|
-
const baselineFrame = findVisualFrame(context.frames, config.startStepId, check.scriptName, 'after');
|
|
636
|
-
const comparisonFrame = findVisualFrame(context.frames, config.endStepId, check.scriptName, 'after');
|
|
653
|
+
const baselineFrame = findVisualFrame(context.frames.slice(check.frameStart, check.endFrameStart), config.startStepId, check.scriptName, 'after');
|
|
654
|
+
const comparisonFrame = findVisualFrame(context.frames.slice(check.endFrameStart), config.endStepId, check.scriptName, 'after');
|
|
637
655
|
if (!baselineFrame || !comparisonFrame) {
|
|
638
656
|
const missing = [
|
|
639
657
|
baselineFrame ? '' : '开始节点截图',
|
|
@@ -688,6 +706,18 @@ async function runStep(
|
|
|
688
706
|
meta: RunStepMeta,
|
|
689
707
|
): Promise<RunStepResult | void> {
|
|
690
708
|
throwIfReplayStopped();
|
|
709
|
+
step = resolveVariableStep(step);
|
|
710
|
+
if (step.type === 'extractVariable') {
|
|
711
|
+
const config = validateExtraction(step.extractVariable);
|
|
712
|
+
const scope = variableContext.getStore();
|
|
713
|
+
if (!scope) throw new Error('变量作用域未初始化');
|
|
714
|
+
const elementId = await findElement(sessionId, step);
|
|
715
|
+
const endpoint = config.attribute === 'text' ? 'text' : `attribute/${encodeURIComponent(config.attribute)}`;
|
|
716
|
+
const payload = await appiumRequest<AppiumValueResponse<unknown>>(`/session/${sessionId}/element/${elementId}/${endpoint}`);
|
|
717
|
+
if (payload.value === null || payload.value === undefined) throw new Error(`组件不存在属性:${config.attribute}`);
|
|
718
|
+
scope.set({ name: config.name, value: String(payload.value), sensitive: config.sensitive }, true);
|
|
719
|
+
return `已提取变量:${config.name}`;
|
|
720
|
+
}
|
|
691
721
|
if (step.type === 'delay') {
|
|
692
722
|
await wait(Math.max(0, step.timeoutMs || 1000));
|
|
693
723
|
return;
|
|
@@ -918,6 +948,7 @@ async function readNativeControlState(sessionId: string, step: AppiumRecordedSte
|
|
|
918
948
|
}
|
|
919
949
|
|
|
920
950
|
async function evaluateCondition(sessionId: string, deviceId: string, step: AppiumRecordedStepRecord) {
|
|
951
|
+
step = resolveVariableStep(step);
|
|
921
952
|
if (step.type === 'loop') {
|
|
922
953
|
if (step.loop?.exitWhen === 'never') return false;
|
|
923
954
|
const selector = toAppiumUsing(step.selector!);
|
|
@@ -1005,12 +1036,17 @@ async function appendStepDiagnostics(lines: string[], deviceId: string, step: Ap
|
|
|
1005
1036
|
}
|
|
1006
1037
|
|
|
1007
1038
|
type ReplayStepOptions = {
|
|
1039
|
+
frameStart?: number;
|
|
1008
1040
|
skipAppInitialization?: boolean;
|
|
1009
1041
|
scriptName?: string;
|
|
1010
1042
|
};
|
|
1011
1043
|
|
|
1012
|
-
function shouldSkipLinkedScriptInitStep(options: ReplayStepOptions, step: AppiumRecordedStepRecord) {
|
|
1013
|
-
|
|
1044
|
+
function shouldSkipLinkedScriptInitStep(options: ReplayStepOptions, step: AppiumRecordedStepRecord, steps: AppiumRecordedStepRecord[]) {
|
|
1045
|
+
if (!options.skipAppInitialization) return false;
|
|
1046
|
+
// Only the leading initialization sequence is redundant in a linked script.
|
|
1047
|
+
// Launches inside branches or after other operations are intentional actions.
|
|
1048
|
+
return steps.slice(0, steps.indexOf(step) + 1).every(item =>
|
|
1049
|
+
!item.flow?.parentConditionId && (item.type === 'launchApp' || item.type === 'clearAppData'));
|
|
1014
1050
|
}
|
|
1015
1051
|
|
|
1016
1052
|
function linkedScriptInitSkipReason(step: AppiumRecordedStepRecord) {
|
|
@@ -1030,7 +1066,7 @@ async function replayLinkedScript(
|
|
|
1030
1066
|
const scriptId = step.value || '';
|
|
1031
1067
|
if (!scriptId) throw new Error(`${step.label} 缺少连接脚本 ID`);
|
|
1032
1068
|
if (stack.includes(scriptId)) throw new Error(`${step.label} 检测到循环连接脚本`);
|
|
1033
|
-
const linkedScript =
|
|
1069
|
+
const linkedScript = replayContext.getStore()?.scripts.get(scriptId);
|
|
1034
1070
|
if (!linkedScript) throw new Error(`${step.label} 指向的脚本不存在`);
|
|
1035
1071
|
|
|
1036
1072
|
if (linkedScript.appActivity) {
|
|
@@ -1058,10 +1094,12 @@ async function replayLinkedScript(
|
|
|
1058
1094
|
|
|
1059
1095
|
lines.push(`连接脚本开始:${linkedScript.name}`);
|
|
1060
1096
|
const nextStack = [...stack, linkedScript.id];
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1097
|
+
const parent = variableContext.getStore()!;
|
|
1098
|
+
const child = parent.child(linkedScript.variables || [], step.parameters);
|
|
1099
|
+
await variableContext.run(child, () => replayScriptSteps(sessionId, deviceId, linkedScript.steps, lines, nextStack, {
|
|
1100
|
+
skipAppInitialization: true, scriptName: linkedScript.name,
|
|
1101
|
+
}));
|
|
1102
|
+
if (!replayContext.getStore()?.flowEnded) parent.acceptReturns(child, step.returns);
|
|
1065
1103
|
lines.push(`连接脚本完成:${linkedScript.name}`);
|
|
1066
1104
|
}
|
|
1067
1105
|
|
|
@@ -1090,7 +1128,7 @@ async function replayLinearSteps(
|
|
|
1090
1128
|
for (const [index, step] of steps.entries()) {
|
|
1091
1129
|
if (replayContext.getStore()?.flowEnded) return;
|
|
1092
1130
|
throwIfReplayStopped();
|
|
1093
|
-
if (shouldSkipLinkedScriptInitStep(options, step)) {
|
|
1131
|
+
if (shouldSkipLinkedScriptInitStep(options, step, steps)) {
|
|
1094
1132
|
lines.push(`[节点 ${index + 1}] 跳过:${step.label}(${linkedScriptInitSkipReason(step)})`);
|
|
1095
1133
|
continue;
|
|
1096
1134
|
}
|
|
@@ -1104,11 +1142,13 @@ async function replayLinearSteps(
|
|
|
1104
1142
|
const result = await runStep(sessionId, deviceId, step, {
|
|
1105
1143
|
nodeNumber: index + 1,
|
|
1106
1144
|
scriptName: options.scriptName || '',
|
|
1145
|
+
frameStart: options.frameStart,
|
|
1107
1146
|
});
|
|
1108
1147
|
softFailed = appendRunStepResult(lines, index + 1, result);
|
|
1109
1148
|
}
|
|
1110
1149
|
lines.push(`[节点 ${index + 1}] 完成:${step.label}${softFailed ? '(存在软失败,已继续)' : ''}`);
|
|
1111
1150
|
await captureReplayFrame(sessionId, step, index + 1, options.scriptName || '', 'after', completedStepStatus(step, softFailed));
|
|
1151
|
+
processPendingVisualChecks(lines);
|
|
1112
1152
|
} catch (error) {
|
|
1113
1153
|
if (isReplayStopped(error)) {
|
|
1114
1154
|
lines.push(`[节点 ${index + 1}] 已终止:${step.label}`);
|
|
@@ -1144,7 +1184,7 @@ async function replayFlowSteps(
|
|
|
1144
1184
|
traversal.visit(step);
|
|
1145
1185
|
const loopContext = traversal.context(step);
|
|
1146
1186
|
const frameStep = loopContext ? { ...step, label: `${step.label}(${loopContext})` } : step;
|
|
1147
|
-
if (shouldSkipLinkedScriptInitStep(options, step)) {
|
|
1187
|
+
if (shouldSkipLinkedScriptInitStep(options, step, steps)) {
|
|
1148
1188
|
lines.push(`[节点 ${index + 1}] 跳过:${step.label}(${linkedScriptInitSkipReason(step)})`);
|
|
1149
1189
|
index = nextIndexAfterStep(index, step);
|
|
1150
1190
|
continue;
|
|
@@ -1225,11 +1265,13 @@ async function replayFlowSteps(
|
|
|
1225
1265
|
const result = await runStep(sessionId, deviceId, step, {
|
|
1226
1266
|
nodeNumber: index + 1,
|
|
1227
1267
|
scriptName: options.scriptName || '',
|
|
1268
|
+
frameStart: options.frameStart,
|
|
1228
1269
|
});
|
|
1229
1270
|
softFailed = appendRunStepResult(lines, index + 1, result);
|
|
1230
1271
|
}
|
|
1231
1272
|
lines.push(`[节点 ${index + 1}] 完成:${step.label}${softFailed ? '(存在软失败,已继续)' : ''}`);
|
|
1232
1273
|
await captureReplayFrame(sessionId, frameStep, index + 1, options.scriptName || '', 'after', completedStepStatus(step, softFailed));
|
|
1274
|
+
processPendingVisualChecks(lines);
|
|
1233
1275
|
index = nextIndexAfterStep(index, step);
|
|
1234
1276
|
} catch (error) {
|
|
1235
1277
|
if (isReplayStopped(error)) {
|
|
@@ -1278,6 +1320,7 @@ async function replayScriptSteps(
|
|
|
1278
1320
|
options: ReplayStepOptions = {},
|
|
1279
1321
|
) {
|
|
1280
1322
|
validateLoopSteps(steps);
|
|
1323
|
+
options = { ...options, frameStart: replayContext.getStore()?.frames.length || 0 };
|
|
1281
1324
|
let trailingLinkIndex = steps.length;
|
|
1282
1325
|
while (
|
|
1283
1326
|
trailingLinkIndex > 0
|
|
@@ -1319,30 +1362,57 @@ export async function replayAppiumScript(
|
|
|
1319
1362
|
deviceId: string,
|
|
1320
1363
|
onOutput?: (line: string) => void,
|
|
1321
1364
|
signal?: AbortSignal,
|
|
1365
|
+
runOptions: { parameters?: TestVariable[]; globalVariables?: TestVariable[]; linkedScripts?: AppiumRecordedScriptRecord[] } = {},
|
|
1322
1366
|
) {
|
|
1323
1367
|
const targetDeviceId = deviceId || script.deviceId;
|
|
1324
1368
|
if (!targetDeviceId) throw new Error('未检测到可用设备');
|
|
1325
1369
|
if (!script.steps.length) throw new Error('脚本没有可回放步骤');
|
|
1326
1370
|
|
|
1371
|
+
const scope = new VariableScope(runOptions.globalVariables ?? getPresetVariables(), script.variables || []);
|
|
1372
|
+
const scripts = new Map((runOptions.linkedScripts ?? linkedScriptSnapshot(script)).map(item => [item.id, item]));
|
|
1373
|
+
for (const item of validateVariables(runOptions.parameters)) scope.set(item);
|
|
1374
|
+
// 提前登记子脚本敏感预设,避免进入子脚本前的原始日志或截图泄露。
|
|
1375
|
+
const visited = new Set<string>();
|
|
1376
|
+
const registerSecrets = (current: AppiumRecordedScriptRecord) => {
|
|
1377
|
+
if (visited.has(current.id)) return;
|
|
1378
|
+
visited.add(current.id);
|
|
1379
|
+
(current.variables || []).forEach(item => scope.track(item));
|
|
1380
|
+
for (const step of current.steps) {
|
|
1381
|
+
(step.parameters || []).forEach(item => scope.track(item));
|
|
1382
|
+
if (step.extractVariable) scope.track({ ...step.extractVariable, value: '' });
|
|
1383
|
+
if (step.type === 'runScript' && step.value) {
|
|
1384
|
+
const child = scripts.get(step.value);
|
|
1385
|
+
if (child) registerSecrets(child);
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
};
|
|
1389
|
+
registerSecrets(script);
|
|
1390
|
+
|
|
1327
1391
|
const startedAt = new Date();
|
|
1392
|
+
const appVersion = await readAppVersion(targetDeviceId, script.appPackage).catch(() => '');
|
|
1328
1393
|
const lines: string[] = [];
|
|
1329
1394
|
const pushLine = lines.push.bind(lines);
|
|
1330
1395
|
lines.push = (...items: string[]) => {
|
|
1396
|
+
items = items.map(item => scope.redact(item));
|
|
1331
1397
|
const length = pushLine(...items);
|
|
1332
1398
|
items.forEach((line) => onOutput?.(line));
|
|
1333
1399
|
return length;
|
|
1334
1400
|
};
|
|
1335
1401
|
const context = {
|
|
1336
1402
|
signal,
|
|
1337
|
-
appiumLog: (line) => lines.push(line),
|
|
1403
|
+
appiumLog: (line) => { if (!scope.privacy.enabled) lines.push(line); },
|
|
1338
1404
|
serverUrl: configuredAppiumServerUrl(),
|
|
1339
1405
|
deviceId: targetDeviceId,
|
|
1406
|
+
scripts,
|
|
1340
1407
|
frames: [] as AppiumReplayFrame[],
|
|
1408
|
+
historyEvents: [] as AppiumReplayFrame[],
|
|
1341
1409
|
visualChecks: [] as AppiumReplayVisualCheck[],
|
|
1410
|
+
visualStartOffsets: new Map<string, number>(),
|
|
1342
1411
|
pendingVisualChecks: [] as PendingVisualChangeCheck[],
|
|
1343
1412
|
softFailureCount: 0,
|
|
1344
1413
|
};
|
|
1345
|
-
return replayContext.run(context, async () => {
|
|
1414
|
+
return variableContext.run(scope, () => replayContext.run(context, async () => {
|
|
1415
|
+
if (scope.privacy.enabled) lines.push('敏感变量保护已启用:不保存报告截图、截图节点文件或 Appium 原始日志');
|
|
1346
1416
|
lines.push(
|
|
1347
1417
|
`目标设备:${targetDeviceId}`,
|
|
1348
1418
|
`App 包名:${script.appPackage}`,
|
|
@@ -1360,7 +1430,7 @@ export async function replayAppiumScript(
|
|
|
1360
1430
|
if (usesManagedAppiumServer()) {
|
|
1361
1431
|
lines.push('正在启动本次回放的 Appium 服务...');
|
|
1362
1432
|
lines.push('----- Appium 服务端原始日志开始 -----');
|
|
1363
|
-
managedAppium = await startManagedAppiumServer((line) => lines.push(line), signal);
|
|
1433
|
+
managedAppium = await startManagedAppiumServer((line) => { if (!scope.privacy.enabled) lines.push(line); }, signal);
|
|
1364
1434
|
context.serverUrl = managedAppium.serverUrl;
|
|
1365
1435
|
}
|
|
1366
1436
|
lines.push(`Appium 服务:${appiumServerUrl()}`);
|
|
@@ -1426,21 +1496,24 @@ export async function replayAppiumScript(
|
|
|
1426
1496
|
}
|
|
1427
1497
|
|
|
1428
1498
|
const completedAt = new Date();
|
|
1499
|
+
const safeFrames = scope.privacy.enabled ? [] : scope.scrub(context.frames);
|
|
1500
|
+
const safeChecks = scope.scrub(context.visualChecks.map(check => scope.privacy.enabled
|
|
1501
|
+
? { ...check, baselineBase64: '', comparisonBase64: '', diffBase64: '' } : check));
|
|
1429
1502
|
let reportPath = '';
|
|
1430
1503
|
let reportId = '';
|
|
1431
1504
|
let logPath = '';
|
|
1432
1505
|
let htmlReportPath = '';
|
|
1433
1506
|
try {
|
|
1434
1507
|
const report = await createAppiumReplayReport({
|
|
1435
|
-
script,
|
|
1508
|
+
script: scope.scrub(script),
|
|
1436
1509
|
deviceId: targetDeviceId,
|
|
1437
1510
|
success,
|
|
1438
1511
|
stopped,
|
|
1439
|
-
output: lines.join('\n'),
|
|
1512
|
+
output: scope.redact(lines.join('\n')),
|
|
1440
1513
|
startedAt,
|
|
1441
1514
|
completedAt,
|
|
1442
|
-
frames:
|
|
1443
|
-
visualChecks:
|
|
1515
|
+
frames: safeFrames,
|
|
1516
|
+
visualChecks: safeChecks,
|
|
1444
1517
|
});
|
|
1445
1518
|
reportPath = report.filePath;
|
|
1446
1519
|
reportId = report.id;
|
|
@@ -1455,12 +1528,20 @@ export async function replayAppiumScript(
|
|
|
1455
1528
|
return {
|
|
1456
1529
|
success,
|
|
1457
1530
|
stopped,
|
|
1458
|
-
output: lines.join('\n'),
|
|
1531
|
+
output: scope.redact(lines.join('\n')),
|
|
1459
1532
|
reportPath,
|
|
1460
1533
|
reportId,
|
|
1461
1534
|
logPath,
|
|
1462
1535
|
htmlReportPath,
|
|
1463
1536
|
softFailureCount: context.softFailureCount,
|
|
1537
|
+
history: {
|
|
1538
|
+
scriptId: script.id, scriptName: script.name, appPackage: script.appPackage,
|
|
1539
|
+
appVersion, deviceId: targetDeviceId, startedAt: startedAt.toISOString(),
|
|
1540
|
+
durationMs: completedAt.getTime() - startedAt.getTime(),
|
|
1541
|
+
status: stopped ? 'stopped' as const : success ? 'passed' as const : 'failed' as const,
|
|
1542
|
+
output: scope.redact(lines.join('\n')),
|
|
1543
|
+
...scope.scrub(captureHistoryFrames(safeFrames, safeChecks, context.historyEvents)),
|
|
1544
|
+
},
|
|
1464
1545
|
};
|
|
1465
|
-
});
|
|
1546
|
+
}));
|
|
1466
1547
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { getAdbCommand } from '../android-sdk';
|
|
3
|
+
import { htmlImageUrl, type AppiumReplayFrame, type AppiumReplayVisualCheck } from './report';
|
|
4
|
+
import type { HistoryNode } from '../../src/appium-recorder/run-history';
|
|
5
|
+
|
|
6
|
+
export function readAppVersion(deviceId: string, appPackage: string): Promise<string> {
|
|
7
|
+
if (!appPackage) return Promise.resolve('');
|
|
8
|
+
return new Promise(resolve => {
|
|
9
|
+
execFile(getAdbCommand(), ['-s', deviceId, 'shell', 'dumpsys', 'package', appPackage], { timeout: 5000, maxBuffer: 4 * 1024 * 1024 }, (error, stdout) => {
|
|
10
|
+
if (error) return resolve('');
|
|
11
|
+
const name = stdout.match(/\bversionName=([^\r\n]+)/)?.[1]?.trim();
|
|
12
|
+
const code = stdout.match(/\bversionCode=(\d+)/)?.[1];
|
|
13
|
+
resolve(name ? `${name}${code ? ` (${code})` : ''}` : '');
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function captureHistoryFrames(frames: AppiumReplayFrame[], checks: AppiumReplayVisualCheck[], events = frames) {
|
|
19
|
+
const cache = new Map<string, string>();
|
|
20
|
+
const nodes: HistoryNode[] = [];
|
|
21
|
+
const pending = new Map<string, AppiumReplayFrame>();
|
|
22
|
+
for (const frame of events) {
|
|
23
|
+
const key = frame.nodeId;
|
|
24
|
+
if (frame.phase === 'before') { pending.set(key, frame); continue; }
|
|
25
|
+
const start = pending.get(key);
|
|
26
|
+
pending.delete(key);
|
|
27
|
+
if (frame.phase !== 'stopped') nodes.push({ key, label: `${frame.scriptName} / ${frame.nodeLabel}`, failed: frame.phase === 'error', durationMs: start ? Math.max(0, Date.parse(frame.capturedAt) - Date.parse(start.capturedAt)) : null });
|
|
28
|
+
}
|
|
29
|
+
for (const check of checks.filter(check => check.status === 'failed')) {
|
|
30
|
+
const node = nodes.find(node => node.key === check.nodeId);
|
|
31
|
+
if (node) node.failed = true;
|
|
32
|
+
}
|
|
33
|
+
return { nodes, frames: frames.map(frame => ({ key: frame.nodeId, label: frame.nodeLabel, phase: frame.phase, capturedAt: frame.capturedAt, imageUrl: htmlImageUrl(frame.imageBase64, cache) })) };
|
|
34
|
+
}
|
|
@@ -126,7 +126,7 @@ function compressHtmlPngBase64(base64: string) {
|
|
|
126
126
|
}
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
-
function htmlImageUrl(base64: string, cache: Map<string, string>) {
|
|
129
|
+
export function htmlImageUrl(base64: string, cache: Map<string, string>) {
|
|
130
130
|
const cached = cache.get(base64);
|
|
131
131
|
if (cached) return cached;
|
|
132
132
|
|