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.
Files changed (31) hide show
  1. package/CHANGELOG.md +14 -1
  2. package/README.md +15 -1
  3. package/bin/sync-readme-docs.mjs +49 -0
  4. package/package.json +8 -8
  5. package/remote-agent/index.ts +6 -2
  6. package/server/appium-recorder/appium-runner.ts +111 -30
  7. package/server/appium-recorder/history-capture.ts +34 -0
  8. package/server/appium-recorder/report.ts +1 -1
  9. package/server/appium-recorder/repository.ts +32 -1
  10. package/server/appium-recorder/routes.ts +49 -6
  11. package/server/appium-recorder/run-history.ts +34 -0
  12. package/server/appium-recorder/script-export.ts +11 -3
  13. package/server/appium-recorder/variable-store.ts +22 -0
  14. package/server/appium-recorder/variables.ts +89 -0
  15. package/src/appium-recorder/AppiumPage.vue +91 -34
  16. package/src/appium-recorder/RunHistoryDialog.vue +153 -0
  17. package/src/appium-recorder/api.ts +23 -2
  18. package/src/appium-recorder/components/FlowStepEditor.vue +28 -2
  19. package/src/appium-recorder/components/NestedConditionBranches.vue +5 -4
  20. package/src/appium-recorder/components/PresetVariables.vue +109 -0
  21. package/src/appium-recorder/components/RecordedSteps.vue +3 -6
  22. package/src/appium-recorder/components/ScriptParameterSettings.vue +31 -0
  23. package/src/appium-recorder/components/VariableExtractionSettings.vue +17 -0
  24. package/src/appium-recorder/components/VariableTable.vue +43 -0
  25. package/src/appium-recorder/components/VisualChangeEndPicker.vue +21 -0
  26. package/src/appium-recorder/flow-graph.ts +2 -8
  27. package/src/appium-recorder/flow-labels.ts +4 -1
  28. package/src/appium-recorder/run-history.ts +50 -0
  29. package/src/appium-recorder/types.ts +7 -0
  30. package/src/appium-recorder/variables.ts +34 -0
  31. 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,包含 Android、Android Playground、Core、Playground、Shared 和 Web
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,包含 Android、Android Playground、Core、Playground、Shared 和 Web
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.31",
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 scripts/sync-readme-docs.mjs",
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.2.tgz",
42
- "@midscene/android-playground": "https://registry.npmjs.org/@midscene/android-playground/-/android-playground-1.12.2.tgz",
43
- "@midscene/core": "https://registry.npmjs.org/@midscene/core/-/core-1.12.2.tgz",
44
- "@midscene/playground": "https://registry.npmjs.org/@midscene/playground/-/playground-1.12.2.tgz",
45
- "@midscene/shared": "https://registry.npmjs.org/@midscene/shared/-/shared-1.12.2.tgz",
46
- "@midscene/web": "https://registry.npmjs.org/@midscene/web/-/web-1.12.2.tgz",
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",
@@ -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://172.16.20.116:5173');
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 { getAppiumRecordedScript, type AppiumRecordedScriptRecord, type AppiumRecordedStepRecord } from './repository';
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 preferred = preferredPhase === 'before'
564
- ? matches.find((frame) => frame.phase === preferredPhase)
565
- : [...matches].reverse().find((frame) => frame.phase === preferredPhase);
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
- // Visual checks compare captured replay frames after the path finishes, so
628
- // they never block or mutate the device state for downstream nodes.
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
- return Boolean(options.skipAppInitialization && (step.type === 'launchApp' || step.type === 'clearAppData'));
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 = getAppiumRecordedScript(scriptId);
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
- await replayScriptSteps(sessionId, deviceId, linkedScript.steps, lines, nextStack, {
1062
- skipAppInitialization: true,
1063
- scriptName: linkedScript.name,
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: context.frames,
1443
- visualChecks: context.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