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.
- package/CHANGELOG.md +9 -0
- package/README.md +9 -0
- package/artemis-main/apps/admin_console/server.py +33 -0
- package/package.json +1 -1
- package/server/appium-recorder/appium-runner.ts +231 -78
- package/server/appium-recorder/element-tap.test.ts +171 -6
- package/server/appium-recorder/managed-cleanup.test.ts +7 -7
- package/server/appium-recorder/replay-lifecycle.test.ts +4 -4
- package/server/appium-recorder/repository.ts +39 -9
- package/server/appium-recorder/request-timeout.ts +5 -0
- package/server/appium-recorder/routes.ts +38 -15
- package/server/appium-recorder/script-import.test.ts +45 -0
- package/server/appium-recorder/tree-dump.ts +44 -0
- package/server/remote-agents/replay-integration.test.ts +4 -4
- package/src/App.vue +10 -10
- package/src/appium-recorder/AppiumPage.vue +58 -12
- package/src/appium-recorder/RunHistoryDialog.vue +91 -51
- package/src/appium-recorder/api.ts +4 -0
- package/src/appium-recorder/components/ComponentTree.vue +72 -10
- package/src/appium-recorder/components/FlowStepEditor.vue +51 -9
- package/src/appium-recorder/components/NodeDetail.vue +129 -52
- package/src/appium-recorder/components/RecordedSteps.vue +1 -0
- package/src/appium-recorder/components/ScriptParameterSettings.vue +44 -10
- package/src/appium-recorder/components/VariableTable.vue +44 -5
- package/src/appium-recorder/tree.ts +1 -0
- package/src/components/config/AiDeduplicationSettings.vue +0 -1
- package/src/components/config/MidsceneConfigPanel.vue +12 -2
- 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
|
|
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
|
-
|
|
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-
|
|
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-[
|
|
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-
|
|
71
|
-
assert.equal(elementRequestCount,
|
|
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
|
|
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:
|
|
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('/
|
|
56
|
-
|
|
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 =
|
|
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: '
|
|
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(
|
|
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,
|
|
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('/
|
|
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: '
|
|
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(
|
|
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
|
|
487
|
-
const
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
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
|
|
335
|
-
if (!
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
|
|
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(/"/g, '"').replace(/'/g, "'").replace(/</g, '<').replace(/>/g, '>').replace(/&/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,
|
|
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('/
|
|
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: '
|
|
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(
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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"
|