android-midscene-automation 1.1.3 → 1.1.5

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 (28) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +9 -0
  3. package/artemis-main/apps/admin_console/server.py +33 -0
  4. package/package.json +1 -1
  5. package/server/appium-recorder/appium-runner.ts +231 -78
  6. package/server/appium-recorder/element-tap.test.ts +171 -6
  7. package/server/appium-recorder/managed-cleanup.test.ts +7 -7
  8. package/server/appium-recorder/replay-lifecycle.test.ts +4 -4
  9. package/server/appium-recorder/repository.ts +39 -9
  10. package/server/appium-recorder/request-timeout.ts +5 -0
  11. package/server/appium-recorder/routes.ts +38 -15
  12. package/server/appium-recorder/script-import.test.ts +45 -0
  13. package/server/appium-recorder/tree-dump.ts +44 -0
  14. package/server/remote-agents/replay-integration.test.ts +4 -4
  15. package/src/App.vue +10 -10
  16. package/src/appium-recorder/AppiumPage.vue +58 -12
  17. package/src/appium-recorder/RunHistoryDialog.vue +91 -51
  18. package/src/appium-recorder/api.ts +4 -0
  19. package/src/appium-recorder/components/ComponentTree.vue +72 -10
  20. package/src/appium-recorder/components/FlowStepEditor.vue +51 -9
  21. package/src/appium-recorder/components/NodeDetail.vue +129 -52
  22. package/src/appium-recorder/components/RecordedSteps.vue +1 -0
  23. package/src/appium-recorder/components/ScriptParameterSettings.vue +44 -10
  24. package/src/appium-recorder/components/VariableTable.vue +44 -5
  25. package/src/appium-recorder/tree.ts +1 -0
  26. package/src/components/config/AiDeduplicationSettings.vue +0 -1
  27. package/src/components/config/MidsceneConfigPanel.vue +12 -2
  28. package/src/style.css +296 -55
@@ -7,13 +7,14 @@ import { tmpdir } from 'node:os';
7
7
  import { join } from 'node:path';
8
8
  import type { AppiumRecordedScriptRecord } from './repository';
9
9
 
10
- test('element taps use the live element center through ADB without waiting for Appium click acknowledgement', async () => {
10
+ test('element taps and long presses use the live element center through ADB without waiting for Appium gesture acknowledgement', async () => {
11
11
  const root = await mkdtemp(join(tmpdir(), 'element-tap-'));
12
12
  const env = { ...process.env };
13
13
  const original = { execFile: childProcess.execFile, fetch: globalThis.fetch };
14
14
  const appiumPaths: string[] = [];
15
15
  const adbCalls: string[][] = [];
16
16
  let elementRequestCount = 0;
17
+ let absentRequestCount = 0;
17
18
  try {
18
19
  const sdk = join(root, 'sdk');
19
20
  await mkdir(join(sdk, 'platform-tools'), { recursive: true });
@@ -26,7 +27,10 @@ test('element taps use the live element center through ADB without waiting for A
26
27
  });
27
28
  childProcess.execFile = ((_command, args: string[], _options, callback) => {
28
29
  adbCalls.push(args);
29
- queueMicrotask(() => callback(null, '', ''));
30
+ const stdout = args.includes('activities')
31
+ ? 'topResumedActivity=ActivityRecord{abc example.test/.Login t1}'
32
+ : '';
33
+ queueMicrotask(() => callback(null, stdout, ''));
30
34
  return {};
31
35
  }) as typeof childProcess.execFile;
32
36
  syncBuiltinESMExports();
@@ -37,13 +41,19 @@ test('element taps use the live element center through ADB without waiting for A
37
41
  return Response.json({ value: { sessionId: 'test-session' } });
38
42
  }
39
43
  if (path === '/session/test-session/element' && init?.method === 'POST') {
44
+ const selector = JSON.parse(String(init.body)).value;
45
+ if (selector === 'example:id/absent') {
46
+ absentRequestCount += 1;
47
+ await new Promise(resolve => setTimeout(resolve, 70));
48
+ return Response.json({ value: { error: 'no such element', message: 'not found' } }, { status: 404 });
49
+ }
40
50
  elementRequestCount += 1;
41
51
  return Response.json({ value: { 'element-6066-11e4-a52e-4f735466cecf': `button-${elementRequestCount}` } });
42
52
  }
43
- if (path === '/session/test-session/element/button-2/rect') {
53
+ if (path === '/session/test-session/element/button-4/rect') {
44
54
  return Response.json({ value: { error: 'stale element reference', message: 'element changed' } }, { status: 404 });
45
55
  }
46
- if (/\/element\/button-[13]\/rect$/.test(path)) {
56
+ if (/\/element\/button-(?:[1-3]|[5-6])\/rect$/.test(path)) {
47
57
  return Response.json({ value: { x: 10, y: 20, width: 100, height: 60 } });
48
58
  }
49
59
  return Response.json({ value: null });
@@ -52,6 +62,16 @@ test('element taps use the live element center through ADB without waiting for A
52
62
  const script: AppiumRecordedScriptRecord = {
53
63
  id: 'tap', name: 'tap', deviceId: 'fake-device', appPackage: '', appActivity: '', createdAt: '', updatedAt: '',
54
64
  steps: [{
65
+ id: 'recorded', type: 'tap', label: '点击录制页面按钮',
66
+ selector: { strategy: 'id', value: 'example:id/recorded', unique: true },
67
+ fallback: { strategy: 'bounds', centerX: 90, centerY: 190 },
68
+ pageBefore: { activity: 'example.test/.Login' },
69
+ }, {
70
+ id: 'invalid-recorded-position', type: 'tap', label: '无效录制坐标改用实时组件位置',
71
+ selector: { strategy: 'id', value: 'example:id/live-position', unique: true },
72
+ fallback: { strategy: 'bounds', centerX: 0, centerY: 0 },
73
+ pageBefore: { activity: 'example.test/.Login' },
74
+ }, {
55
75
  id: 'login', type: 'tap', label: '点击登录按钮',
56
76
  selector: { strategy: 'id', value: 'example:id/login', unique: true },
57
77
  fallback: { strategy: 'bounds', centerX: 999, centerY: 999 },
@@ -59,6 +79,19 @@ test('element taps use the live element center through ADB without waiting for A
59
79
  id: 'refreshing', type: 'tap', label: '点击刷新列表中的按钮',
60
80
  selector: { strategy: 'id', value: 'example:id/refreshing', unique: true },
61
81
  fallback: { strategy: 'bounds', centerX: 999, centerY: 999 },
82
+ }, {
83
+ id: 'long-press', type: 'longPress', label: '长按设备', longPressMode: 'element', timeoutMs: 800,
84
+ selector: { strategy: 'id', value: 'example:id/device', unique: true },
85
+ fallback: { strategy: 'bounds', centerX: 999, centerY: 999 },
86
+ }, {
87
+ id: 'home', type: 'key', label: 'Home', keyCode: 3,
88
+ }, {
89
+ id: 'back-if-exists', type: 'backIfExists', label: '弹窗存在则返回',
90
+ selector: { strategy: 'id', value: 'example:id/dialog', unique: true }, timeoutMs: 100,
91
+ }, {
92
+ id: 'absent', type: 'tapIfExists', label: '不存在则跳过',
93
+ selector: { strategy: 'id', value: 'example:id/absent', unique: true },
94
+ selectorChain: [{ strategy: 'xpath', value: "//*[@resource-id='example:id/absent']", unique: true }], timeoutMs: 100,
62
95
  }],
63
96
  };
64
97
  const result = await replayAppiumScript(script, 'fake-device', undefined, undefined, {
@@ -67,13 +100,145 @@ test('element taps use the live element center through ADB without waiting for A
67
100
  });
68
101
  assert.equal(result.success, true, result.output);
69
102
  assert.ok(appiumPaths.includes('GET /session/test-session/element/button-1/rect'));
70
- assert.ok(appiumPaths.includes('GET /session/test-session/element/button-3/rect'));
71
- assert.equal(elementRequestCount, 3, 're-locate once after a stale element rect');
103
+ assert.ok(appiumPaths.includes('GET /session/test-session/element/button-5/rect'));
104
+ assert.equal(elementRequestCount, 7, 'always locate component ids first and re-locate once after a stale rect');
105
+ assert.equal(absentRequestCount, 1, 'do not repeat the primary selector with an unsafe deadline remainder');
72
106
  assert.equal(appiumPaths.some(path => path.endsWith('/click')), false, 'must not call the blocking Appium click endpoint');
107
+ assert.equal(appiumPaths.some(path => path.endsWith('/execute/sync')), false, 'must not call the blocking Appium gesture endpoint for long presses');
108
+ assert.equal(appiumPaths.some(path => path.endsWith('/press_keycode')), false, 'must not call the blocking Appium key endpoint');
73
109
  assert.deepEqual(adbCalls.filter(args => args.includes('tap')).map(args => args.slice(-4)), [
74
110
  ['input', 'tap', '60', '50'],
75
111
  ['input', 'tap', '60', '50'],
112
+ ['input', 'tap', '60', '50'],
113
+ ['input', 'tap', '60', '50'],
76
114
  ]);
115
+ assert.deepEqual(adbCalls.find(args => args.includes('swipe'))?.slice(-7), ['input', 'swipe', '60', '50', '60', '50', '800']);
116
+ assert.deepEqual(adbCalls.filter(args => args.includes('keyevent')).map(args => args.slice(-3)), [
117
+ ['input', 'keyevent', '3'],
118
+ ['input', 'keyevent', '4'],
119
+ ]);
120
+ } finally {
121
+ childProcess.execFile = original.execFile;
122
+ globalThis.fetch = original.fetch;
123
+ syncBuiltinESMExports();
124
+ for (const key of ['ANDROID_MIDSCENE_DATA_ROOT', 'ANDROID_HOME', 'ANDROID_SDK_ROOT', 'APPIUM_SERVER_URL']) {
125
+ if (env[key] === undefined) delete process.env[key]; else process.env[key] = env[key];
126
+ }
127
+ await rm(root, { recursive: true, force: true });
128
+ }
129
+ });
130
+
131
+ test('text matching does not start another Appium query at the node deadline', async () => {
132
+ const root = await mkdtemp(join(tmpdir(), 'text-deadline-'));
133
+ const env = { ...process.env };
134
+ const original = { execFile: childProcess.execFile, fetch: globalThis.fetch };
135
+ let elementQueries = 0;
136
+ try {
137
+ const sdk = join(root, 'sdk');
138
+ await mkdir(join(sdk, 'platform-tools'), { recursive: true });
139
+ await writeFile(join(sdk, 'platform-tools', 'adb'), '');
140
+ Object.assign(process.env, {
141
+ ANDROID_MIDSCENE_DATA_ROOT: root,
142
+ ANDROID_HOME: sdk,
143
+ ANDROID_SDK_ROOT: sdk,
144
+ APPIUM_SERVER_URL: 'http://external.invalid',
145
+ });
146
+ childProcess.execFile = ((_command, _args: string[], _options, callback) => {
147
+ queueMicrotask(() => callback(null, '', ''));
148
+ return {};
149
+ }) as typeof childProcess.execFile;
150
+ syncBuiltinESMExports();
151
+ globalThis.fetch = async (input, init) => {
152
+ const path = new URL(String(input)).pathname;
153
+ if (path === '/session' && init?.method === 'POST') {
154
+ return Response.json({ value: { sessionId: 'deadline-session' } });
155
+ }
156
+ if (path === '/session/deadline-session/elements') {
157
+ elementQueries += 1;
158
+ await new Promise(resolve => setTimeout(resolve, 70));
159
+ return Response.json({ value: [] });
160
+ }
161
+ return Response.json({ value: null });
162
+ };
163
+ const { replayAppiumScript } = await import('./appium-runner');
164
+ const script: AppiumRecordedScriptRecord = {
165
+ id: 'deadline', name: 'deadline', deviceId: 'fake-device', appPackage: '', appActivity: '', createdAt: '', updatedAt: '',
166
+ steps: [{
167
+ id: 'text', type: 'textClick', label: '短时间查找文字', value: '不存在', timeoutMs: 100,
168
+ timeoutBranch: 'no', flow: { noTargetId: 'done' },
169
+ }, { id: 'done', type: 'noop', label: '继续执行' }],
170
+ };
171
+ const result = await replayAppiumScript(script, 'fake-device', undefined, undefined, {
172
+ screenshotReport: false,
173
+ reportSummaryEnabled: false,
174
+ });
175
+ assert.equal(result.success, true, result.output);
176
+ assert.equal(elementQueries, 1, 'the final 30ms must not start another Appium query');
177
+ assert.match(result.output, /判断超时,按配置进入右侧分支/);
178
+ } finally {
179
+ childProcess.execFile = original.execFile;
180
+ globalThis.fetch = original.fetch;
181
+ syncBuiltinESMExports();
182
+ for (const key of ['ANDROID_MIDSCENE_DATA_ROOT', 'ANDROID_HOME', 'ANDROID_SDK_ROOT', 'APPIUM_SERVER_URL']) {
183
+ if (env[key] === undefined) delete process.env[key]; else process.env[key] = env[key];
184
+ }
185
+ await rm(root, { recursive: true, force: true });
186
+ }
187
+ });
188
+
189
+ test('optional tap recovers a blocked UiAutomator2 session and uses the ADB hierarchy', async () => {
190
+ const root = await mkdtemp(join(tmpdir(), 'optional-recovery-'));
191
+ const env = { ...process.env };
192
+ const original = { execFile: childProcess.execFile, fetch: globalThis.fetch };
193
+ const adbCalls: string[][] = [];
194
+ let sessions = 0;
195
+ try {
196
+ const sdk = join(root, 'sdk');
197
+ await mkdir(join(sdk, 'platform-tools'), { recursive: true });
198
+ await writeFile(join(sdk, 'platform-tools', 'adb'), '');
199
+ Object.assign(process.env, {
200
+ ANDROID_MIDSCENE_DATA_ROOT: root,
201
+ ANDROID_HOME: sdk,
202
+ ANDROID_SDK_ROOT: sdk,
203
+ APPIUM_SERVER_URL: 'http://external.invalid',
204
+ });
205
+ childProcess.execFile = ((_command, args: string[], _options, callback) => {
206
+ adbCalls.push(args);
207
+ const stdout = args.includes('cat')
208
+ ? '<hierarchy><node resource-id="example:id/optional" bounds="[20,40][120,100]" /></hierarchy>'
209
+ : args.includes('dump') ? 'UI hierchary dumped' : '';
210
+ queueMicrotask(() => callback(null, stdout, ''));
211
+ return {};
212
+ }) as typeof childProcess.execFile;
213
+ syncBuiltinESMExports();
214
+ globalThis.fetch = async (input, init) => {
215
+ const path = new URL(String(input)).pathname;
216
+ if (path === '/session' && init?.method === 'POST') {
217
+ sessions += 1;
218
+ return Response.json({ value: { sessionId: sessions === 1 ? 'blocked-session' : 'recovered-session' } });
219
+ }
220
+ if (path === '/session/blocked-session/element') {
221
+ await new Promise((_resolve, reject) => init?.signal?.addEventListener('abort', () => reject(init.signal?.reason), { once: true }));
222
+ }
223
+ return Response.json({ value: null });
224
+ };
225
+ const { replayAppiumScript } = await import('./appium-runner');
226
+ const script: AppiumRecordedScriptRecord = {
227
+ id: 'recover', name: 'recover', deviceId: 'fake-device', appPackage: '', appActivity: '', createdAt: '', updatedAt: '',
228
+ steps: [{
229
+ id: 'optional', type: 'tapIfExists', label: '存在则点击', timeoutMs: 100,
230
+ selector: { strategy: 'id', value: 'example:id/optional', unique: true },
231
+ fallback: { strategy: 'bounds', centerX: 999, centerY: 999 },
232
+ }],
233
+ };
234
+ const result = await replayAppiumScript(script, 'fake-device', undefined, undefined, {
235
+ screenshotReport: false,
236
+ reportSummaryEnabled: false,
237
+ });
238
+ assert.equal(result.success, true, result.output);
239
+ assert.equal(sessions, 2, 'replace the blocked session before continuing');
240
+ assert.deepEqual(adbCalls.find(args => args.includes('tap'))?.slice(-4), ['input', 'tap', '70', '70']);
241
+ assert.match(result.output, /ADB 组件树发现目标并已点击/);
77
242
  } finally {
78
243
  childProcess.execFile = original.execFile;
79
244
  globalThis.fetch = original.fetch;
@@ -15,7 +15,7 @@ test('replay survives command idle periods and cleans sessions after success, ab
15
15
  const calls: string[][] = [];
16
16
  let mode = 'success', stopped = false;
17
17
  let elapsedMs = 0, lastCommandMs = 0, commandTimeoutMs = 60_000;
18
- let keyCommands = 0, deletedSessions = 0;
18
+ let gestureCommands = 0, deletedSessions = 0;
19
19
  let controller = new AbortController();
20
20
  try {
21
21
  const sdk = join(root, 'sdk');
@@ -47,13 +47,13 @@ test('replay survives command idle periods and cleans sessions after success, ab
47
47
  if (path === '/session' && init?.method === 'POST') {
48
48
  if (mode === 'creation-failure') return Response.json({ value: { error: 'session not created', message: 'creation failed' } }, { status: 500 });
49
49
  const capabilities = JSON.parse(String(init.body)).capabilities.alwaysMatch;
50
- assert.deepEqual(capabilities['appium:settings'], { waitForIdleTimeout: 1000 }, 'bound UiAutomator idle waits for animated screens');
50
+ assert.deepEqual(capabilities['appium:settings'], { waitForIdleTimeout: 0 }, 'disable UiAutomator idle waits for continuously updating screens');
51
51
  commandTimeoutMs = (capabilities['appium:newCommandTimeout'] ?? 60) * 1000;
52
52
  lastCommandMs = elapsedMs;
53
53
  return Response.json({ value: { sessionId: 'test-session' } });
54
54
  }
55
- if (path.endsWith('/press_keycode')) {
56
- keyCommands++;
55
+ if (path.endsWith('/execute/sync')) {
56
+ gestureCommands++;
57
57
  // Model Appium's idle expiry without waiting a real minute or touching a device.
58
58
  if (commandTimeoutMs && elapsedMs - lastCommandMs > commandTimeoutMs) {
59
59
  return Response.json({ value: { error: 'invalid session id', message: 'New Command Timeout expired' } }, { status: 404 });
@@ -70,7 +70,7 @@ test('replay survives command idle periods and cleans sessions after success, ab
70
70
  };
71
71
  for (mode of ['success', 'long-idle', 'abort', 'creation-failure', 'cleanup-failure', 'external', 'external-abort']) {
72
72
  calls.length = 0;
73
- elapsedMs = lastCommandMs = keyCommands = deletedSessions = 0;
73
+ elapsedMs = lastCommandMs = gestureCommands = deletedSessions = 0;
74
74
  controller = new AbortController();
75
75
  const external = mode.startsWith('external');
76
76
  const aborted = mode.endsWith('abort');
@@ -79,7 +79,7 @@ test('replay survives command idle periods and cleans sessions after success, ab
79
79
  else delete process.env.APPIUM_SERVER_URL;
80
80
  const replayScript = longIdle ? { ...script, steps: [
81
81
  ...script.steps,
82
- { id: 'home', type: 'key' as const, label: 'Home', keyCode: 3 },
82
+ { id: 'pinch', type: 'pinch' as const, label: '缩放', pinch: { direction: 'out' as const, centerX: 200, centerY: 300, percent: 0.5 } },
83
83
  ] } : script;
84
84
  const result = await replayAppiumScript(replayScript, 'test-device', line => {
85
85
  // ADB operations and model inference do not reset Appium's command timer.
@@ -93,7 +93,7 @@ test('replay survives command idle periods and cleans sessions after success, ab
93
93
  assert.deepEqual(calls.map(args => args.at(-1)).sort(), ['io.appium.uiautomator2.server', 'io.appium.uiautomator2.server.test']);
94
94
  }
95
95
  if (longIdle) {
96
- assert.equal(keyCommands, 1, 'execute Home after the long interval without Appium commands');
96
+ assert.equal(gestureCommands, 1, 'execute an Appium gesture after the long interval without Appium commands');
97
97
  assert.equal(result.success, true, result.output);
98
98
  }
99
99
  if (aborted) assert.equal(result.stopped, true);
@@ -70,7 +70,7 @@ test('ADB cancellation and deadlines cover actions; external sessions survive id
70
70
  // A local HTTP server models the idle lease at accelerated time, without a real device.
71
71
  globalThis.fetch = original.fetch;
72
72
  globalThis.setInterval = ((fn, ms, ...args) => original.setInterval(fn, ms === 15000 ? 15 : ms, ...args)) as typeof setInterval;
73
- let active = false, timeoutSeconds = 0, heartbeats = 0, homeKeys = 0, deleted = 0;
73
+ let active = false, timeoutSeconds = 0, heartbeats = 0, gestures = 0, deleted = 0;
74
74
  let expiry: ReturnType<typeof setTimeout> | undefined;
75
75
  let createdResolve: () => void = () => {};
76
76
  const renew = () => { clearTimeout(expiry); expiry = setTimeout(() => { active = false; }, timeoutSeconds * 3); };
@@ -89,7 +89,7 @@ test('ADB cancellation and deadlines cover actions; external sessions survive id
89
89
  } else {
90
90
  renew();
91
91
  if (req.url?.endsWith('/timeouts')) heartbeats++;
92
- if (req.url?.endsWith('/press_keycode')) homeKeys++;
92
+ if (req.url?.endsWith('/execute/sync')) gestures++;
93
93
  res.end('{"value":{}}');
94
94
  }
95
95
  });
@@ -97,9 +97,9 @@ test('ADB cancellation and deadlines cover actions; external sessions survive id
97
97
  process.env.APPIUM_SERVER_URL = `http://127.0.0.1:${(server.address() as { port: number }).port}`;
98
98
  try {
99
99
  mode = 'long-idle'; controller = new AbortController();
100
- const result = await run([{ id: 'wait', type: 'delay', label: 'idle', timeoutMs: 450 }, { id: 'home', type: 'key', label: 'Home', keyCode: 3 }]);
100
+ const result = await run([{ id: 'wait', type: 'delay', label: 'idle', timeoutMs: 450 }, { id: 'pinch', type: 'pinch', label: '缩放', pinch: { direction: 'out', centerX: 200, centerY: 300, percent: 0.5 } }]);
101
101
  assert.equal(result.success, true, result.output);
102
- assert.equal(timeoutSeconds, 60); assert.ok(heartbeats > 3); assert.equal(homeKeys, 1); assert.equal(deleted, 1);
102
+ assert.equal(timeoutSeconds, 60); assert.ok(heartbeats > 3); assert.equal(gestures, 1); assert.equal(deleted, 1);
103
103
  const heartbeatCount = heartbeats;
104
104
  await new Promise(resolve => setTimeout(resolve, 40));
105
105
  assert.equal(heartbeats, heartbeatCount, 'heartbeat stops after cleanup');
@@ -475,6 +475,7 @@ export function deleteAppiumRecordedScript(id: string) {
475
475
  }
476
476
 
477
477
  export function importAppiumRecordedScript(input: {
478
+ sourceId?: string;
478
479
  variables?: TestVariable[];
479
480
  name: string;
480
481
  appPackage: string;
@@ -482,16 +483,45 @@ export function importAppiumRecordedScript(input: {
482
483
  deviceId?: string;
483
484
  steps: AppiumRecordedStepRecord[];
484
485
  }) {
486
+ return importAppiumRecordedScripts([input])[0];
487
+ }
488
+
489
+ export function importAppiumRecordedScripts(inputs: Array<{
490
+ sourceId?: string;
491
+ variables?: TestVariable[];
492
+ name: string;
493
+ appPackage: string;
494
+ appActivity?: string;
495
+ deviceId?: string;
496
+ steps: AppiumRecordedStepRecord[];
497
+ }>) {
485
498
  initDb();
486
- const baseName = input.name.trim() || '导入脚本';
487
- const existingNames = new Set(listAppiumRecordedScripts().map((script) => script.name));
488
- let name = baseName;
489
- let suffix = 2;
490
- while (existingNames.has(name)) {
491
- name = `${baseName} (${suffix})`;
492
- suffix += 1;
493
- }
494
- return saveAppiumRecordedScript({ ...input, name });
499
+ const usedNames = new Set(listAppiumRecordedScripts().map((script) => script.name));
500
+ const sourceIds = new Set<string>();
501
+ const planned = inputs.map((input) => {
502
+ const sourceId = input.sourceId?.trim() || '';
503
+ if (sourceId && sourceIds.has(sourceId)) throw new Error(`导入文件包含重复脚本 ID:${sourceId}`);
504
+ if (sourceId) sourceIds.add(sourceId);
505
+
506
+ const baseName = input.name.trim() || '导入脚本';
507
+ let name = baseName;
508
+ let suffix = 2;
509
+ while (usedNames.has(name)) name = `${baseName} (${suffix++})`;
510
+ usedNames.add(name);
511
+ return { ...input, sourceId, id: createId('appium_script'), name };
512
+ });
513
+ const importedIds = new Map(planned.filter(item => item.sourceId).map(item => [item.sourceId, item.id]));
514
+
515
+ return planned.map((input) => {
516
+ const steps = input.steps.map(step => (
517
+ step.type === 'runScript' && step.value && importedIds.has(step.value)
518
+ ? { ...step, value: importedIds.get(step.value) }
519
+ : step
520
+ ));
521
+ const script = saveAppiumRecordedScript({ ...input, id: input.id, steps });
522
+ if (!script) throw new Error(`脚本「${input.name}」导入失败`);
523
+ return script;
524
+ });
495
525
  }
496
526
 
497
527
  export function saveAppiumReplayReport(input: Omit<AppiumReplayReportRecord, 'id' | 'createdAt'>) {
@@ -7,6 +7,11 @@ export function withElementDeadline<T>(timeoutMs: number, action: () => Promise<
7
7
  return deadline.run(Date.now() + Math.max(1, timeoutMs), action);
8
8
  }
9
9
 
10
+ export function remainingElementDeadlineMs() {
11
+ const value = deadline.getStore();
12
+ return value === undefined ? Number.POSITIVE_INFINITY : value - Date.now();
13
+ }
14
+
10
15
  // 响应体也受同一个期限约束,避免收到 HTTP 头后继续无限等待。
11
16
  export async function timedAppiumFetch(url: string, init: RequestInit, timeoutMs: number) {
12
17
  const remaining = Math.max(1, Math.min(timeoutMs, (deadline.getStore() ?? Infinity) - Date.now()));
@@ -19,6 +19,7 @@ import {
19
19
  linkedScriptSnapshot,
20
20
  getAppiumRecordedScript,
21
21
  importAppiumRecordedScript,
22
+ importAppiumRecordedScripts,
22
23
  listAppiumRecordedScripts,
23
24
  saveAppiumRecordedScript,
24
25
  type AppiumRecordedStepRecord,
@@ -322,7 +323,20 @@ export async function handleAppiumRecorderRequest(
322
323
  if (pathname === '/api/appium-recorder/scripts/import' && req.method === 'POST') {
323
324
  const parsed = await readBody<{
324
325
  schemaVersion?: number;
326
+ files?: Array<{
327
+ schemaVersion?: number;
328
+ script?: {
329
+ id?: string;
330
+ name?: string;
331
+ appPackage?: string;
332
+ appActivity?: string;
333
+ deviceId?: string;
334
+ steps?: AppiumRecordedStepRecord[];
335
+ variables?: TestVariable[];
336
+ };
337
+ }>;
325
338
  script?: {
339
+ id?: string;
326
340
  name?: string;
327
341
  appPackage?: string;
328
342
  appActivity?: string;
@@ -331,22 +345,31 @@ export async function handleAppiumRecorderRequest(
331
345
  variables?: TestVariable[];
332
346
  };
333
347
  }>(req);
334
- const imported = parsed.script;
335
- if (!imported || typeof imported !== 'object') throw new Error('导入文件缺少 script 数据');
336
- if (!Array.isArray(imported.steps)) throw new Error('导入文件的 steps 格式无效');
337
- const invalidStep = imported.steps.some((step) => (
338
- !step || typeof step !== 'object' || typeof step.id !== 'string' || typeof step.type !== 'string'
339
- ));
340
- if (invalidStep) throw new Error('导入文件包含无效节点');
341
- const script = importAppiumRecordedScript({
342
- name: imported.name || '',
343
- appPackage: imported.appPackage || '',
344
- appActivity: imported.appActivity || '',
345
- deviceId: imported.deviceId || '',
346
- steps: imported.steps,
347
- variables: imported.variables,
348
+ const files = Array.isArray(parsed.files) ? parsed.files : [parsed];
349
+ if (!files.length) throw new Error('没有可导入的脚本文件');
350
+ const imports = files.map((file, index) => {
351
+ const imported = file.script;
352
+ const prefix = files.length > 1 ? `第 ${index + 1} 个导入文件` : '导入文件';
353
+ if (!imported || typeof imported !== 'object') throw new Error(`${prefix}缺少 script 数据`);
354
+ if (!Array.isArray(imported.steps)) throw new Error(`${prefix}的 steps 格式无效`);
355
+ const invalidStep = imported.steps.some((step) => (
356
+ !step || typeof step !== 'object' || typeof step.id !== 'string' || typeof step.type !== 'string'
357
+ ));
358
+ if (invalidStep) throw new Error(`${prefix}包含无效节点`);
359
+ return {
360
+ sourceId: imported.id || '',
361
+ name: imported.name || '',
362
+ appPackage: imported.appPackage || '',
363
+ appActivity: imported.appActivity || '',
364
+ deviceId: imported.deviceId || '',
365
+ steps: imported.steps,
366
+ variables: imported.variables,
367
+ };
348
368
  });
349
- sendJson(res, { script });
369
+ const scripts = files.length === 1
370
+ ? [importAppiumRecordedScript(imports[0])]
371
+ : importAppiumRecordedScripts(imports);
372
+ sendJson(res, { script: scripts[0], scripts });
350
373
  return true;
351
374
  }
352
375
 
@@ -0,0 +1,45 @@
1
+ import assert from 'node:assert/strict';
2
+ import { mkdtemp, rm } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { test } from 'node:test';
6
+
7
+ test('batch import remaps linked script ids and keeps imported names unique', async () => {
8
+ const root = await mkdtemp(join(tmpdir(), 'appium-script-import-'));
9
+ const previousRoot = process.env.ANDROID_MIDSCENE_DATA_ROOT;
10
+ process.env.ANDROID_MIDSCENE_DATA_ROOT = root;
11
+ try {
12
+ const { importAppiumRecordedScripts } = await import('./repository');
13
+ const scripts = importAppiumRecordedScripts([
14
+ {
15
+ sourceId: 'exported-root',
16
+ name: '回归测试',
17
+ appPackage: 'test.app',
18
+ steps: [{ id: 'link', type: 'runScript', label: '连接脚本 子流程', value: 'exported-child' }],
19
+ },
20
+ {
21
+ sourceId: 'exported-child',
22
+ name: '子流程',
23
+ appPackage: 'test.app',
24
+ steps: [{ id: 'delay', type: 'delay', label: '等待', timeoutMs: 100 }],
25
+ },
26
+ ]);
27
+
28
+ assert.equal(scripts.length, 2);
29
+ assert.notEqual(scripts[0].id, 'exported-root');
30
+ assert.notEqual(scripts[1].id, 'exported-child');
31
+ assert.equal(scripts[0].steps[0].value, scripts[1].id);
32
+
33
+ const duplicate = importAppiumRecordedScripts([{
34
+ sourceId: 'another-child',
35
+ name: '子流程',
36
+ appPackage: 'test.app',
37
+ steps: [],
38
+ }])[0];
39
+ assert.equal(duplicate.name, '子流程 (2)');
40
+ } finally {
41
+ if (previousRoot === undefined) delete process.env.ANDROID_MIDSCENE_DATA_ROOT;
42
+ else process.env.ANDROID_MIDSCENE_DATA_ROOT = previousRoot;
43
+ await rm(root, { recursive: true, force: true });
44
+ }
45
+ });
@@ -2,6 +2,50 @@ import { randomUUID } from 'node:crypto';
2
2
 
3
3
  type ExecText = (command: string, args: string[]) => Promise<string>;
4
4
 
5
+ export type HierarchySelector = {
6
+ strategy: 'accessibilityId' | 'id' | 'androidUiAutomator' | 'xpath' | 'bounds';
7
+ value?: string;
8
+ };
9
+
10
+ export type HierarchyBounds = { left: number; top: number; right: number; bottom: number; centerX: number; centerY: number };
11
+
12
+ function decodeXml(value: string) {
13
+ return value
14
+ .replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&')
15
+ .replace(/&#(\d+);/g, (_match, code) => String.fromCodePoint(Number(code)));
16
+ }
17
+
18
+ function selectorAttribute(selector: HierarchySelector): { name: string; value: string } | undefined {
19
+ const value = selector.value || '';
20
+ if (selector.strategy === 'id') return { name: 'resource-id', value };
21
+ if (selector.strategy === 'accessibilityId') return { name: 'content-desc', value };
22
+ if (selector.strategy === 'xpath') {
23
+ const match = value.match(/@(resource-id|content-desc|text)\s*=\s*(['"])(.*?)\2/);
24
+ return match ? { name: match[1], value: decodeXml(match[3]) } : undefined;
25
+ }
26
+ if (selector.strategy === 'androidUiAutomator') {
27
+ const match = value.match(/\.(resourceId|description|text)\("((?:\\.|[^"])*)"\)/);
28
+ if (!match) return undefined;
29
+ const name = { resourceId: 'resource-id', description: 'content-desc', text: 'text' }[match[1]];
30
+ return { name, value: match[2].replace(/\\"/g, '"').replace(/\\\\/g, '\\') };
31
+ }
32
+ return undefined;
33
+ }
34
+
35
+ export function findHierarchyBounds(xml: string, selectors: HierarchySelector[]) {
36
+ const targets = selectors.map(selectorAttribute).filter((item): item is { name: string; value: string } => Boolean(item));
37
+ if (!targets.length) return undefined;
38
+ for (const tag of xml.match(/<node\b[^>]*>/g) || []) {
39
+ const attributes = new Map(Array.from(tag.matchAll(/([\w:-]+)="([^"]*)"/g), match => [match[1], decodeXml(match[2])]));
40
+ if (!targets.some(target => attributes.get(target.name) === target.value)) continue;
41
+ const bounds = attributes.get('bounds')?.match(/^\[(\d+),(\d+)\]\[(\d+),(\d+)\]$/);
42
+ if (!bounds) continue;
43
+ const [left, top, right, bottom] = bounds.slice(1).map(Number);
44
+ return { left, top, right, bottom, centerX: Math.round((left + right) / 2), centerY: Math.round((top + bottom) / 2) };
45
+ }
46
+ return undefined;
47
+ }
48
+
5
49
  export async function readFreshWindowHierarchy(deviceId: string, adb: string, execText: ExecText) {
6
50
  // dump 失败可能仍以 0 退出。每次独立文件,避免误读上一次成功抓取的树。
7
51
  const remotePath = `/data/local/tmp/midscene_uidump_${randomUUID()}.xml`;
@@ -16,14 +16,14 @@ test('real agent receives stop during replay and retries result delivery without
16
16
  const { listRemoteAndroidDevices } = await import('./registry');
17
17
  const { saveAppiumRecordedScript } = await import('../appium-recorder/repository');
18
18
  const { listRunHistory } = await import('../appium-recorder/run-history');
19
- let sessions = 0, deletes = 0, keys = 0, resultAttempts = 0;
19
+ let sessions = 0, deletes = 0, gestures = 0, resultAttempts = 0;
20
20
  const server = createServer(async (req, res) => {
21
21
  if (req.url === '/session') {
22
22
  sessions++; res.setHeader('Content-Type', 'application/json'); res.end('{"value":{"sessionId":"test-session"}}'); return;
23
23
  }
24
24
  if (req.url?.startsWith('/session/')) {
25
25
  if (req.method === 'DELETE') deletes++;
26
- if (req.url.endsWith('/press_keycode')) keys++;
26
+ if (req.url.endsWith('/execute/sync')) gestures++;
27
27
  res.setHeader('Content-Type', 'application/json'); res.end('{"value":{}}'); return;
28
28
  }
29
29
  if (req.url === '/api/remote-agents/result' && ++resultAttempts === 1) {
@@ -46,7 +46,7 @@ test('real agent receives stop during replay and retries result delivery without
46
46
  const base = `http://127.0.0.1:${(server.address() as { port: number }).port}`;
47
47
  const script = saveAppiumRecordedScript({ name: 'remote-test', appPackage: 'example.test', steps: [
48
48
  { id: 'wait', type: 'delay', label: 'wait', timeoutMs: 60000 },
49
- { id: 'home', type: 'key', label: 'Home', keyCode: 3 },
49
+ { id: 'pinch', type: 'pinch', label: '缩放', pinch: { direction: 'out', centerX: 200, centerY: 300, percent: 0.5 } },
50
50
  ] });
51
51
  agent = spawn(process.execPath, ['--import', 'tsx', resolve('remote-agent/index.ts'), '--server', base, '--agent-id', 'integration-agent'], {
52
52
  env: { ...process.env, ANDROID_MIDSCENE_DATA_ROOT: join(root, 'agent'), ANDROID_HOME: sdk, ANDROID_SDK_ROOT: sdk, APPIUM_SERVER_URL: base, PATH: `${bin}:${process.env.PATH}` },
@@ -63,7 +63,7 @@ test('real agent receives stop during replay and retries result delivery without
63
63
  const result = await running;
64
64
  assert.equal(result.stopped, true, stderr);
65
65
  assert.equal(sessions, 1, 'retrying the result never replays device actions');
66
- assert.equal(deletes, 1); assert.equal(keys, 0, 'Home must not run after cancellation');
66
+ assert.equal(deletes, 1); assert.equal(gestures, 0, 'the gesture must not run after cancellation');
67
67
  assert.equal(resultAttempts, 2, 'temporary result failure is retried');
68
68
  const history = listRunHistory(script.id);
69
69
  assert.equal(history.length, 1); assert.equal(history[0]?.status, 'stopped'); assert.equal(history[0]?.deviceId, deviceId);
package/src/App.vue CHANGED
@@ -790,7 +790,15 @@ onUnmounted(() => {
790
790
  width="760px"
791
791
  class="code-dialog"
792
792
  >
793
- <div class="code-dialog__toolbar">
793
+ <el-input
794
+ v-if="codeDialog.editing"
795
+ v-model="codeDialog.draftCode"
796
+ type="textarea"
797
+ resize="none"
798
+ class="code-dialog__editor"
799
+ />
800
+ <pre v-else class="code-block code-dialog__body"><code>{{ codeDialog.code || '暂无代码' }}</code></pre>
801
+ <template #footer>
794
802
  <el-button :icon="CopyDocument" @click="copyDialogCode">复制</el-button>
795
803
  <el-button v-if="!codeDialog.editing" type="primary" :icon="Edit" @click="editCodeDialog">
796
804
  编辑
@@ -803,15 +811,7 @@ onUnmounted(() => {
803
811
  保存
804
812
  </el-button>
805
813
  </template>
806
- </div>
807
- <el-input
808
- v-if="codeDialog.editing"
809
- v-model="codeDialog.draftCode"
810
- type="textarea"
811
- resize="none"
812
- class="code-dialog__editor"
813
- />
814
- <pre v-else class="code-block code-dialog__body"><code>{{ codeDialog.code || '暂无代码' }}</code></pre>
814
+ </template>
815
815
  </el-dialog>
816
816
 
817
817
  <AppSidebar :active-menu="activeMenu" :collapsed="sidebarCollapsed"