android-midscene-automation 0.1.13 → 0.1.14
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 +30 -0
- package/README.md +39 -51
- package/USAGE.md +1018 -0
- package/package.json +2 -1
- package/server/android-sdk.ts +104 -0
- package/server/appium-recorder/appium-runner.ts +144 -35
- package/server/appium-recorder/report.ts +138 -0
- package/server/appium-recorder/repository.ts +78 -0
- package/server/appium-recorder/routes.ts +141 -14
- package/server/config.ts +12 -0
- package/server/http-api.ts +9 -6
- package/src/App.vue +23 -23
- package/src/appium-recorder/AppiumPage.vue +403 -38
- package/src/appium-recorder/api.ts +62 -5
- package/src/appium-recorder/components/FlowStepEditor.vue +175 -0
- package/src/appium-recorder/components/RecordedSteps.vue +514 -277
- package/src/appium-recorder/types.ts +2 -0
- package/src/pages/ConfigPage.vue +28 -0
- package/src/style.css +162 -89
- package/src/types.ts +4 -0
|
@@ -32,6 +32,7 @@ export type AppiumRecordedStepRecord = {
|
|
|
32
32
|
| 'pinch'
|
|
33
33
|
| 'runScript';
|
|
34
34
|
label: string;
|
|
35
|
+
note?: string;
|
|
35
36
|
selector?: {
|
|
36
37
|
strategy: 'accessibilityId' | 'id' | 'androidUiAutomator' | 'xpath' | 'bounds';
|
|
37
38
|
value?: string;
|
|
@@ -80,6 +81,7 @@ export type AppiumRecordedStepRecord = {
|
|
|
80
81
|
parentBranch?: 'yes' | 'no';
|
|
81
82
|
successTargetId?: string;
|
|
82
83
|
failureTargetId?: string;
|
|
84
|
+
textMatch?: 'contains' | 'exact';
|
|
83
85
|
collapsed?: boolean;
|
|
84
86
|
};
|
|
85
87
|
pageBefore?: {
|
|
@@ -137,6 +139,17 @@ type AppiumRecordedScriptRow = {
|
|
|
137
139
|
updated_at: string;
|
|
138
140
|
};
|
|
139
141
|
|
|
142
|
+
export type AppiumReplayReportRecord = {
|
|
143
|
+
id: string;
|
|
144
|
+
scriptId: string;
|
|
145
|
+
scriptName: string;
|
|
146
|
+
success: boolean;
|
|
147
|
+
filePath: string;
|
|
148
|
+
startedAt: string;
|
|
149
|
+
completedAt: string;
|
|
150
|
+
createdAt: string;
|
|
151
|
+
};
|
|
152
|
+
|
|
140
153
|
let initialized = false;
|
|
141
154
|
|
|
142
155
|
function createAppiumRecordedScriptsTable() {
|
|
@@ -156,10 +169,28 @@ function createAppiumRecordedScriptsTable() {
|
|
|
156
169
|
`);
|
|
157
170
|
}
|
|
158
171
|
|
|
172
|
+
function createAppiumReplayReportsTable() {
|
|
173
|
+
runSql(`
|
|
174
|
+
CREATE TABLE IF NOT EXISTS appium_replay_reports (
|
|
175
|
+
id TEXT PRIMARY KEY,
|
|
176
|
+
script_id TEXT NOT NULL,
|
|
177
|
+
script_name TEXT NOT NULL,
|
|
178
|
+
success INTEGER NOT NULL,
|
|
179
|
+
file_path TEXT NOT NULL,
|
|
180
|
+
started_at TEXT NOT NULL,
|
|
181
|
+
completed_at TEXT NOT NULL,
|
|
182
|
+
created_at TEXT NOT NULL
|
|
183
|
+
);
|
|
184
|
+
CREATE INDEX IF NOT EXISTS idx_appium_replay_reports_script_id
|
|
185
|
+
ON appium_replay_reports(script_id, created_at DESC);
|
|
186
|
+
`);
|
|
187
|
+
}
|
|
188
|
+
|
|
159
189
|
function initDb() {
|
|
160
190
|
if (initialized) return;
|
|
161
191
|
initialized = true;
|
|
162
192
|
createAppiumRecordedScriptsTable();
|
|
193
|
+
createAppiumReplayReportsTable();
|
|
163
194
|
const columns = querySql<{ name: string }>('PRAGMA table_info(appium_recorded_scripts);').map((column) => column.name);
|
|
164
195
|
if (columns.includes('steps_json') || !columns.includes('flow_json')) {
|
|
165
196
|
runSql('DROP TABLE appium_recorded_scripts;');
|
|
@@ -320,3 +351,50 @@ export function deleteAppiumRecordedScript(id: string) {
|
|
|
320
351
|
WHERE id = ${sqlString(id)};
|
|
321
352
|
`);
|
|
322
353
|
}
|
|
354
|
+
|
|
355
|
+
export function importAppiumRecordedScript(input: {
|
|
356
|
+
name: string;
|
|
357
|
+
appPackage: string;
|
|
358
|
+
appActivity?: string;
|
|
359
|
+
deviceId?: string;
|
|
360
|
+
steps: AppiumRecordedStepRecord[];
|
|
361
|
+
}) {
|
|
362
|
+
initDb();
|
|
363
|
+
const baseName = input.name.trim() || '导入脚本';
|
|
364
|
+
const existingNames = new Set(listAppiumRecordedScripts().map((script) => script.name));
|
|
365
|
+
let name = baseName;
|
|
366
|
+
let suffix = 2;
|
|
367
|
+
while (existingNames.has(name)) {
|
|
368
|
+
name = `${baseName} (${suffix})`;
|
|
369
|
+
suffix += 1;
|
|
370
|
+
}
|
|
371
|
+
return saveAppiumRecordedScript({ ...input, name });
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export function saveAppiumReplayReport(input: Omit<AppiumReplayReportRecord, 'id' | 'createdAt'>) {
|
|
375
|
+
initDb();
|
|
376
|
+
const id = createId('appium_report');
|
|
377
|
+
const createdAt = new Date().toISOString();
|
|
378
|
+
runSql(`
|
|
379
|
+
INSERT INTO appium_replay_reports (
|
|
380
|
+
id,
|
|
381
|
+
script_id,
|
|
382
|
+
script_name,
|
|
383
|
+
success,
|
|
384
|
+
file_path,
|
|
385
|
+
started_at,
|
|
386
|
+
completed_at,
|
|
387
|
+
created_at
|
|
388
|
+
) VALUES (
|
|
389
|
+
${sqlString(id)},
|
|
390
|
+
${sqlString(input.scriptId)},
|
|
391
|
+
${sqlString(input.scriptName)},
|
|
392
|
+
${input.success ? 1 : 0},
|
|
393
|
+
${sqlString(input.filePath)},
|
|
394
|
+
${sqlString(input.startedAt)},
|
|
395
|
+
${sqlString(input.completedAt)},
|
|
396
|
+
${sqlString(createdAt)}
|
|
397
|
+
);
|
|
398
|
+
`);
|
|
399
|
+
return { id, ...input, createdAt };
|
|
400
|
+
}
|
|
@@ -6,12 +6,17 @@ import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
|
6
6
|
import {
|
|
7
7
|
deleteAppiumRecordedScript,
|
|
8
8
|
getAppiumRecordedScript,
|
|
9
|
+
importAppiumRecordedScript,
|
|
9
10
|
listAppiumRecordedScripts,
|
|
10
11
|
saveAppiumRecordedScript,
|
|
11
12
|
type AppiumRecordedStepRecord,
|
|
12
13
|
} from './repository';
|
|
13
|
-
import { replayAppiumScript } from './appium-runner';
|
|
14
|
+
import { launchAppOnDevice, replayAppiumScript } from './appium-runner';
|
|
14
15
|
import { isRemoteDeviceId, sendRemoteCommand } from '../remote-agents/registry';
|
|
16
|
+
import { getAdbCommand } from '../android-sdk';
|
|
17
|
+
|
|
18
|
+
const treeDumpTasks = new Map<string, Promise<string>>();
|
|
19
|
+
const replayingDevices = new Set<string>();
|
|
15
20
|
|
|
16
21
|
function sendJson(res: ServerResponse, payload: unknown, statusCode = 200) {
|
|
17
22
|
res.statusCode = statusCode;
|
|
@@ -19,6 +24,17 @@ function sendJson(res: ServerResponse, payload: unknown, statusCode = 200) {
|
|
|
19
24
|
res.end(JSON.stringify(payload));
|
|
20
25
|
}
|
|
21
26
|
|
|
27
|
+
function sendJsonDownload(res: ServerResponse, payload: unknown, fileName: string) {
|
|
28
|
+
res.statusCode = 200;
|
|
29
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
30
|
+
res.setHeader('Content-Disposition', `attachment; filename="appium-script.json"; filename*=UTF-8''${encodeURIComponent(fileName)}`);
|
|
31
|
+
res.end(JSON.stringify(payload, null, 2));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function sendStreamEvent(res: ServerResponse, payload: unknown) {
|
|
35
|
+
res.write(`${JSON.stringify(payload)}\n`);
|
|
36
|
+
}
|
|
37
|
+
|
|
22
38
|
async function readBody<T>(req: IncomingMessage) {
|
|
23
39
|
let body = '';
|
|
24
40
|
req.on('data', (chunk) => {
|
|
@@ -44,19 +60,30 @@ function execFileText(command: string, args: string[] = []) {
|
|
|
44
60
|
}
|
|
45
61
|
|
|
46
62
|
async function dumpWindowHierarchy(deviceId: string) {
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
63
|
+
const activeTask = treeDumpTasks.get(deviceId);
|
|
64
|
+
if (activeTask) return activeTask;
|
|
65
|
+
|
|
66
|
+
const task = (async () => {
|
|
67
|
+
const localPath = path.join(os.tmpdir(), `midscene-appium-${deviceId.replace(/[^\w.-]/g, '_')}-${Date.now()}.xml`);
|
|
68
|
+
const remotePath = '/data/local/tmp/midscene_appium_uidump.xml';
|
|
69
|
+
await execFileText(getAdbCommand(), ['-s', deviceId, 'shell', 'uiautomator', 'dump', remotePath]);
|
|
70
|
+
await execFileText(getAdbCommand(), ['-s', deviceId, 'pull', remotePath, localPath]);
|
|
71
|
+
try {
|
|
72
|
+
return await fs.readFile(localPath, 'utf8');
|
|
73
|
+
} finally {
|
|
74
|
+
await fs.unlink(localPath).catch(() => undefined);
|
|
75
|
+
}
|
|
76
|
+
})();
|
|
77
|
+
treeDumpTasks.set(deviceId, task);
|
|
51
78
|
try {
|
|
52
|
-
return await
|
|
79
|
+
return await task;
|
|
53
80
|
} finally {
|
|
54
|
-
|
|
81
|
+
if (treeDumpTasks.get(deviceId) === task) treeDumpTasks.delete(deviceId);
|
|
55
82
|
}
|
|
56
83
|
}
|
|
57
84
|
|
|
58
85
|
async function getCurrentActivity(deviceId: string) {
|
|
59
|
-
const output = await execFileText(
|
|
86
|
+
const output = await execFileText(getAdbCommand(), ['-s', deviceId, 'shell', 'dumpsys', 'activity', 'activities']);
|
|
60
87
|
const resumedLine = output
|
|
61
88
|
.split(/\r?\n/)
|
|
62
89
|
.find((line) => /(?:topResumedActivity|ResumedActivity|mResumedActivity)/.test(line));
|
|
@@ -64,7 +91,7 @@ async function getCurrentActivity(deviceId: string) {
|
|
|
64
91
|
}
|
|
65
92
|
|
|
66
93
|
async function tapDevice(deviceId: string, x: number, y: number) {
|
|
67
|
-
await execFileText(
|
|
94
|
+
await execFileText(getAdbCommand(), [
|
|
68
95
|
'-s',
|
|
69
96
|
deviceId,
|
|
70
97
|
'shell',
|
|
@@ -76,7 +103,7 @@ async function tapDevice(deviceId: string, x: number, y: number) {
|
|
|
76
103
|
}
|
|
77
104
|
|
|
78
105
|
async function pressDeviceKey(deviceId: string, keyCode: number) {
|
|
79
|
-
await execFileText(
|
|
106
|
+
await execFileText(getAdbCommand(), [
|
|
80
107
|
'-s',
|
|
81
108
|
deviceId,
|
|
82
109
|
'shell',
|
|
@@ -109,6 +136,10 @@ export async function handleAppiumRecorderRequest(
|
|
|
109
136
|
const deviceId = requestUrl.searchParams.get('deviceId')?.trim() || selectedDeviceId;
|
|
110
137
|
if (!deviceId) throw new Error('未检测到可用设备');
|
|
111
138
|
assertDeviceAllowed(deviceId);
|
|
139
|
+
if (replayingDevices.has(deviceId)) {
|
|
140
|
+
sendJson(res, { message: '回放期间已暂停组件树刷新' }, 409);
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
112
143
|
if (isRemoteDeviceId(deviceId)) {
|
|
113
144
|
const data = await sendRemoteCommand(deviceId, 'tree') as { xml?: string; activity?: string; dumpedAt?: string };
|
|
114
145
|
sendJson(res, {
|
|
@@ -160,6 +191,19 @@ export async function handleAppiumRecorderRequest(
|
|
|
160
191
|
return true;
|
|
161
192
|
}
|
|
162
193
|
|
|
194
|
+
if (pathname === '/api/appium-recorder/launch-app' && req.method === 'POST') {
|
|
195
|
+
const parsed = await readBody<{ deviceId?: string; packageName?: string }>(req);
|
|
196
|
+
const deviceId = parsed.deviceId?.trim() || selectedDeviceId;
|
|
197
|
+
const packageName = parsed.packageName?.trim() || '';
|
|
198
|
+
if (!deviceId) throw new Error('未检测到可用设备');
|
|
199
|
+
if (!packageName) throw new Error('请选择预设 App');
|
|
200
|
+
assertDeviceAllowed(deviceId);
|
|
201
|
+
if (isRemoteDeviceId(deviceId)) throw new Error('远程设备暂不支持直接启动 App');
|
|
202
|
+
await launchAppOnDevice(deviceId, packageName);
|
|
203
|
+
sendJson(res, { success: true });
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
|
|
163
207
|
if (pathname === '/api/appium-recorder/scripts' && req.method === 'GET') {
|
|
164
208
|
sendJson(res, { scripts: listAppiumRecordedScripts() });
|
|
165
209
|
return true;
|
|
@@ -186,6 +230,53 @@ export async function handleAppiumRecorderRequest(
|
|
|
186
230
|
return true;
|
|
187
231
|
}
|
|
188
232
|
|
|
233
|
+
if (pathname === '/api/appium-recorder/scripts/import' && req.method === 'POST') {
|
|
234
|
+
const parsed = await readBody<{
|
|
235
|
+
schemaVersion?: number;
|
|
236
|
+
script?: {
|
|
237
|
+
name?: string;
|
|
238
|
+
appPackage?: string;
|
|
239
|
+
appActivity?: string;
|
|
240
|
+
deviceId?: string;
|
|
241
|
+
steps?: AppiumRecordedStepRecord[];
|
|
242
|
+
};
|
|
243
|
+
}>(req);
|
|
244
|
+
const imported = parsed.script;
|
|
245
|
+
if (!imported || typeof imported !== 'object') throw new Error('导入文件缺少 script 数据');
|
|
246
|
+
if (!Array.isArray(imported.steps)) throw new Error('导入文件的 steps 格式无效');
|
|
247
|
+
const invalidStep = imported.steps.some((step) => (
|
|
248
|
+
!step || typeof step !== 'object' || typeof step.id !== 'string' || typeof step.type !== 'string'
|
|
249
|
+
));
|
|
250
|
+
if (invalidStep) throw new Error('导入文件包含无效节点');
|
|
251
|
+
const script = importAppiumRecordedScript({
|
|
252
|
+
name: imported.name || '',
|
|
253
|
+
appPackage: imported.appPackage || '',
|
|
254
|
+
appActivity: imported.appActivity || '',
|
|
255
|
+
deviceId: imported.deviceId || '',
|
|
256
|
+
steps: imported.steps,
|
|
257
|
+
});
|
|
258
|
+
sendJson(res, { script });
|
|
259
|
+
return true;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const exportMatch = pathname.match(/^\/api\/appium-recorder\/scripts\/([^/]+)\/export$/);
|
|
263
|
+
if (exportMatch && req.method === 'GET') {
|
|
264
|
+
const script = getAppiumRecordedScript(decodeURIComponent(exportMatch[1]));
|
|
265
|
+
if (!script) throw new Error('Appium 录制脚本不存在');
|
|
266
|
+
sendJsonDownload(res, {
|
|
267
|
+
schemaVersion: 1,
|
|
268
|
+
exportedAt: new Date().toISOString(),
|
|
269
|
+
script: {
|
|
270
|
+
name: script.name,
|
|
271
|
+
appPackage: script.appPackage,
|
|
272
|
+
appActivity: script.appActivity,
|
|
273
|
+
deviceId: script.deviceId,
|
|
274
|
+
steps: script.steps,
|
|
275
|
+
},
|
|
276
|
+
}, `${script.name}.json`);
|
|
277
|
+
return true;
|
|
278
|
+
}
|
|
279
|
+
|
|
189
280
|
const deleteMatch = pathname.match(/^\/api\/appium-recorder\/scripts\/([^/]+)$/);
|
|
190
281
|
if (deleteMatch && req.method === 'DELETE') {
|
|
191
282
|
deleteAppiumRecordedScript(decodeURIComponent(deleteMatch[1]));
|
|
@@ -200,20 +291,56 @@ export async function handleAppiumRecorderRequest(
|
|
|
200
291
|
if (!script) throw new Error('Appium 录制脚本不存在');
|
|
201
292
|
const deviceId = parsed.deviceId || selectedDeviceId;
|
|
202
293
|
assertDeviceAllowed(deviceId);
|
|
294
|
+
const streamOutput = req.headers.accept?.includes('application/x-ndjson') === true;
|
|
295
|
+
if (streamOutput) {
|
|
296
|
+
res.statusCode = 200;
|
|
297
|
+
res.setHeader('Content-Type', 'application/x-ndjson; charset=utf-8');
|
|
298
|
+
res.setHeader('Cache-Control', 'no-cache, no-transform');
|
|
299
|
+
res.setHeader('X-Accel-Buffering', 'no');
|
|
300
|
+
res.flushHeaders();
|
|
301
|
+
}
|
|
203
302
|
if (isRemoteDeviceId(deviceId)) {
|
|
204
303
|
const result = await sendRemoteCommand(deviceId, 'replay', { script }) as { success?: boolean; output?: string };
|
|
205
|
-
|
|
304
|
+
if (streamOutput) {
|
|
305
|
+
result.output?.split(/\r?\n/).forEach((line) => sendStreamEvent(res, { type: 'log', line }));
|
|
306
|
+
sendStreamEvent(res, { type: 'result', ...result });
|
|
307
|
+
res.end();
|
|
308
|
+
} else {
|
|
309
|
+
sendJson(res, result, result.success ? 200 : 500);
|
|
310
|
+
}
|
|
206
311
|
return true;
|
|
207
312
|
}
|
|
208
|
-
|
|
209
|
-
|
|
313
|
+
replayingDevices.add(deviceId);
|
|
314
|
+
try {
|
|
315
|
+
await treeDumpTasks.get(deviceId)?.catch(() => undefined);
|
|
316
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
317
|
+
const result = await replayAppiumScript(
|
|
318
|
+
script,
|
|
319
|
+
deviceId,
|
|
320
|
+
streamOutput ? (line) => sendStreamEvent(res, { type: 'log', line }) : undefined,
|
|
321
|
+
);
|
|
322
|
+
if (streamOutput) {
|
|
323
|
+
sendStreamEvent(res, { type: 'result', ...result });
|
|
324
|
+
res.end();
|
|
325
|
+
} else {
|
|
326
|
+
sendJson(res, result, result.success ? 200 : 500);
|
|
327
|
+
}
|
|
328
|
+
} finally {
|
|
329
|
+
replayingDevices.delete(deviceId);
|
|
330
|
+
}
|
|
210
331
|
return true;
|
|
211
332
|
}
|
|
212
333
|
|
|
213
334
|
sendJson(res, { message: 'Appium Recorder 接口不存在' }, 404);
|
|
214
335
|
return true;
|
|
215
336
|
} catch (error) {
|
|
216
|
-
|
|
337
|
+
const message = error instanceof Error ? error.message : 'Appium Recorder 请求失败';
|
|
338
|
+
if (res.headersSent) {
|
|
339
|
+
sendStreamEvent(res, { type: 'error', message });
|
|
340
|
+
res.end();
|
|
341
|
+
} else {
|
|
342
|
+
sendJson(res, { message }, 500);
|
|
343
|
+
}
|
|
217
344
|
return true;
|
|
218
345
|
}
|
|
219
346
|
}
|
package/server/config.ts
CHANGED
|
@@ -5,6 +5,10 @@ import { appDataPath } from './paths';
|
|
|
5
5
|
import { loadModelConfigFromDb, saveModelConfigToDb } from './config-store';
|
|
6
6
|
|
|
7
7
|
export type AppConfig = {
|
|
8
|
+
runtime: {
|
|
9
|
+
androidSdkPath: string;
|
|
10
|
+
reportOutputPath: string;
|
|
11
|
+
};
|
|
8
12
|
midscene: {
|
|
9
13
|
model: {
|
|
10
14
|
provider: 'custom' | 'codex';
|
|
@@ -30,6 +34,10 @@ const legacyConfigPath = appDataPath('config.yaml');
|
|
|
30
34
|
|
|
31
35
|
function defaultConfig(): AppConfig {
|
|
32
36
|
return {
|
|
37
|
+
runtime: {
|
|
38
|
+
androidSdkPath: '',
|
|
39
|
+
reportOutputPath: '',
|
|
40
|
+
},
|
|
33
41
|
midscene: {
|
|
34
42
|
model: {
|
|
35
43
|
provider: 'custom',
|
|
@@ -61,6 +69,10 @@ function normalizeEnv(env: unknown): Record<string, string> {
|
|
|
61
69
|
function normalizeConfig(config: Partial<AppConfig> | null | undefined): AppConfig {
|
|
62
70
|
const fallback = defaultConfig();
|
|
63
71
|
return {
|
|
72
|
+
runtime: {
|
|
73
|
+
androidSdkPath: config?.runtime?.androidSdkPath?.trim() || fallback.runtime.androidSdkPath,
|
|
74
|
+
reportOutputPath: config?.runtime?.reportOutputPath?.trim() || fallback.runtime.reportOutputPath,
|
|
75
|
+
},
|
|
64
76
|
midscene: {
|
|
65
77
|
model: {
|
|
66
78
|
provider: config?.midscene?.model?.provider || (
|
package/server/http-api.ts
CHANGED
|
@@ -39,6 +39,7 @@ import { importTestCaseFile, MAX_TEST_CASE_FILE_SIZE } from './test-case-import/
|
|
|
39
39
|
import { handleAppiumRecorderRequest } from './appium-recorder/routes';
|
|
40
40
|
import { handleRemoteAgentRequest } from './remote-agents/routes';
|
|
41
41
|
import { isRemoteDeviceId, listRemoteAndroidDevices, sendRemoteCommand } from './remote-agents/registry';
|
|
42
|
+
import { getAdbCommand } from './android-sdk';
|
|
42
43
|
|
|
43
44
|
async function readBody<T>(req: IncomingMessage) {
|
|
44
45
|
let body = '';
|
|
@@ -110,7 +111,7 @@ type AndroidDisplayInfo = {
|
|
|
110
111
|
};
|
|
111
112
|
|
|
112
113
|
async function listAdbDevices() {
|
|
113
|
-
const output = await execFileText(
|
|
114
|
+
const output = await execFileText(getAdbCommand(), ['devices', '-l']);
|
|
114
115
|
const devices = output
|
|
115
116
|
.split('\n')
|
|
116
117
|
.map((line) => line.trim())
|
|
@@ -133,7 +134,7 @@ async function listAdbDevices() {
|
|
|
133
134
|
}
|
|
134
135
|
|
|
135
136
|
async function getAdbDisplayInfo(deviceId: string) {
|
|
136
|
-
const output = await execFileText(
|
|
137
|
+
const output = await execFileText(getAdbCommand(), ['-s', deviceId, 'shell', 'wm', 'size']);
|
|
137
138
|
const match = output.match(/Override size:\s*(\d+)x(\d+)/i) || output.match(/Physical size:\s*(\d+)x(\d+)/i);
|
|
138
139
|
|
|
139
140
|
if (!match) {
|
|
@@ -270,6 +271,8 @@ const PLAYGROUND_PROXY_PREFIXES = [
|
|
|
270
271
|
'/runtime-info',
|
|
271
272
|
'/interface-info',
|
|
272
273
|
'/screenshot',
|
|
274
|
+
'/action-space',
|
|
275
|
+
'/execute',
|
|
273
276
|
'/interact',
|
|
274
277
|
'/config',
|
|
275
278
|
'/connectivity-test',
|
|
@@ -714,7 +717,7 @@ export function createApiMiddleware() {
|
|
|
714
717
|
return;
|
|
715
718
|
}
|
|
716
719
|
|
|
717
|
-
execFile(
|
|
720
|
+
execFile(getAdbCommand(), ['-s', deviceId, 'exec-out', 'screencap', '-p'], { encoding: 'buffer', maxBuffer: 20 * 1024 * 1024 }, (error, stdout, stderr) => {
|
|
718
721
|
if (error) {
|
|
719
722
|
res.statusCode = 500;
|
|
720
723
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
@@ -752,7 +755,7 @@ export function createApiMiddleware() {
|
|
|
752
755
|
res.end(JSON.stringify({ success: true, result }));
|
|
753
756
|
return;
|
|
754
757
|
}
|
|
755
|
-
const result = await execFileJson(
|
|
758
|
+
const result = await execFileJson(getAdbCommand(), ['-s', deviceId, 'shell', 'input', 'tap', String(x), String(y)]);
|
|
756
759
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
757
760
|
res.end(JSON.stringify({ success: true, ...result }));
|
|
758
761
|
} catch (error) {
|
|
@@ -781,7 +784,7 @@ export function createApiMiddleware() {
|
|
|
781
784
|
res.end(JSON.stringify({ success: true, result }));
|
|
782
785
|
return;
|
|
783
786
|
}
|
|
784
|
-
const result = await execFileJson(
|
|
787
|
+
const result = await execFileJson(getAdbCommand(), ['-s', deviceId, 'shell', 'input', 'keyevent', String(keyCode)]);
|
|
785
788
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
786
789
|
res.end(JSON.stringify({ success: true, ...result }));
|
|
787
790
|
} catch (error) {
|
|
@@ -846,7 +849,7 @@ export function createApiMiddleware() {
|
|
|
846
849
|
return;
|
|
847
850
|
}
|
|
848
851
|
const result = await execFileJson(
|
|
849
|
-
|
|
852
|
+
getAdbCommand(),
|
|
850
853
|
['-s', deviceId, 'shell', 'input', 'swipe', String(startX), String(startY), String(endX), String(endY), String(duration)],
|
|
851
854
|
);
|
|
852
855
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
package/src/App.vue
CHANGED
|
@@ -47,7 +47,10 @@ import type {
|
|
|
47
47
|
SavedScript,
|
|
48
48
|
} from './types';
|
|
49
49
|
|
|
50
|
-
const
|
|
50
|
+
const ACTIVE_MENU_STORAGE_KEY = 'android-midscene-automation:active-menu';
|
|
51
|
+
const menuKeys: MenuKey[] = ['generator', 'automation', 'config', 'appium'];
|
|
52
|
+
const storedMenu = window.localStorage.getItem(ACTIVE_MENU_STORAGE_KEY) as MenuKey | null;
|
|
53
|
+
const activeMenu = ref<MenuKey>(storedMenu && menuKeys.includes(storedMenu) ? storedMenu : 'generator');
|
|
51
54
|
const activeGeneratorMode = ref<GeneratorMode>('ai');
|
|
52
55
|
const sourcePrompt = ref('');
|
|
53
56
|
const steps = ref<ScriptStep[]>([]);
|
|
@@ -141,6 +144,10 @@ const appPresetForm = reactive({
|
|
|
141
144
|
});
|
|
142
145
|
|
|
143
146
|
const configForm = reactive<ConfigForm>({
|
|
147
|
+
runtime: {
|
|
148
|
+
androidSdkPath: '',
|
|
149
|
+
reportOutputPath: '',
|
|
150
|
+
},
|
|
144
151
|
midscene: {
|
|
145
152
|
model: {
|
|
146
153
|
provider: 'custom',
|
|
@@ -587,6 +594,7 @@ const generateWithModel = async () => {
|
|
|
587
594
|
|
|
588
595
|
const loadConfig = async () => {
|
|
589
596
|
const payload = await api.getConfig();
|
|
597
|
+
Object.assign(configForm.runtime, payload.runtime || {});
|
|
590
598
|
Object.assign(configForm.midscene.model, payload.midscene.model);
|
|
591
599
|
Object.keys(configForm.midscene.env).forEach((key) => {
|
|
592
600
|
delete configForm.midscene.env[key];
|
|
@@ -647,13 +655,13 @@ const getMidsceneModelConfigError = () => {
|
|
|
647
655
|
const saveModelConfig = async () => {
|
|
648
656
|
isSavingModelConfig.value = true;
|
|
649
657
|
errorMessage.value = '';
|
|
650
|
-
openActionDialog('
|
|
658
|
+
openActionDialog('保存参数配置');
|
|
651
659
|
try {
|
|
652
660
|
await api.saveConfig(configForm);
|
|
653
661
|
closeActionDialog();
|
|
654
|
-
ElMessage.success('
|
|
662
|
+
ElMessage.success('参数配置已保存');
|
|
655
663
|
} catch (error) {
|
|
656
|
-
const message = error instanceof Error ? error.message : '
|
|
664
|
+
const message = error instanceof Error ? error.message : '参数配置保存失败';
|
|
657
665
|
errorMessage.value = message;
|
|
658
666
|
failActionDialog(message);
|
|
659
667
|
} finally {
|
|
@@ -797,10 +805,6 @@ const refreshAdbPreviewAfterInput = () => {
|
|
|
797
805
|
};
|
|
798
806
|
|
|
799
807
|
const refreshDevicePreview = () => {
|
|
800
|
-
if (activeMenu.value === 'appium') {
|
|
801
|
-
loadAdbPreview();
|
|
802
|
-
return;
|
|
803
|
-
}
|
|
804
808
|
if (devicePreviewMode.value === 'stream' && playgroundFrameUrl.value) {
|
|
805
809
|
playgroundFrameUrl.value = `${api.APP_BASE}/__android_playground__/?ts=${Date.now()}`;
|
|
806
810
|
return;
|
|
@@ -838,6 +842,7 @@ const markBackendOffline = () => {
|
|
|
838
842
|
const loadPlaygroundStatus = async () => {
|
|
839
843
|
if (backendOffline.value) return;
|
|
840
844
|
if (!playgroundDeviceId.value) {
|
|
845
|
+
stopAppiumPreviewTimer();
|
|
841
846
|
playgroundAvailable.value = false;
|
|
842
847
|
playgroundPreviewError.value = '';
|
|
843
848
|
playgroundFrameUrl.value = '';
|
|
@@ -853,7 +858,7 @@ const loadPlaygroundStatus = async () => {
|
|
|
853
858
|
|
|
854
859
|
const matchedDevice = !payload.deviceId || payload.deviceId === playgroundDeviceId.value;
|
|
855
860
|
const hasRealtimeStream = payload.previewKind === 'scrcpy' && payload.sessionConnected === true;
|
|
856
|
-
const hasPlayground = Boolean(payload.available) && matchedDevice &&
|
|
861
|
+
const hasPlayground = Boolean(payload.available) && matchedDevice && hasRealtimeStream;
|
|
857
862
|
playgroundAvailable.value = androidDevices.value.some((item) => item.status === 'device');
|
|
858
863
|
playgroundPreviewError.value =
|
|
859
864
|
matchedDevice && !hasRealtimeStream
|
|
@@ -862,18 +867,13 @@ const loadPlaygroundStatus = async () => {
|
|
|
862
867
|
|
|
863
868
|
const nextSignature = `${payload.url || ''}::${playgroundDeviceId.value}`;
|
|
864
869
|
if (hasPlayground) {
|
|
865
|
-
|
|
870
|
+
stopAppiumPreviewTimer();
|
|
871
|
+
if (nextSignature !== playgroundFrameSignature.value || !playgroundFrameUrl.value) {
|
|
866
872
|
playgroundFrameSignature.value = nextSignature;
|
|
867
873
|
playgroundFrameUrl.value = `${api.APP_BASE}/__android_playground__/?ts=${Date.now()}`;
|
|
868
874
|
}
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
loadAdbPreview();
|
|
872
|
-
}
|
|
873
|
-
} else {
|
|
874
|
-
devicePreviewUrl.value = '';
|
|
875
|
-
devicePreviewMode.value = 'stream';
|
|
876
|
-
}
|
|
875
|
+
devicePreviewUrl.value = '';
|
|
876
|
+
devicePreviewMode.value = 'stream';
|
|
877
877
|
playgroundPreviewError.value = '';
|
|
878
878
|
}
|
|
879
879
|
|
|
@@ -881,6 +881,7 @@ const loadPlaygroundStatus = async () => {
|
|
|
881
881
|
playgroundFrameUrl.value = '';
|
|
882
882
|
playgroundFrameSignature.value = '';
|
|
883
883
|
loadAdbPreview();
|
|
884
|
+
if (activeMenu.value === 'appium') startAppiumPreviewTimer();
|
|
884
885
|
}
|
|
885
886
|
} catch (error) {
|
|
886
887
|
if (isBackendFetchError(error)) {
|
|
@@ -1230,15 +1231,14 @@ watch(
|
|
|
1230
1231
|
);
|
|
1231
1232
|
|
|
1232
1233
|
watch(activeMenu, (menu) => {
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
} else if (menu === 'automation') {
|
|
1234
|
+
window.localStorage.setItem(ACTIVE_MENU_STORAGE_KEY, menu);
|
|
1235
|
+
if (menu === 'appium' || menu === 'automation') {
|
|
1236
1236
|
stopAppiumPreviewTimer();
|
|
1237
1237
|
void loadPlaygroundStatus();
|
|
1238
1238
|
} else {
|
|
1239
1239
|
stopAppiumPreviewTimer();
|
|
1240
1240
|
}
|
|
1241
|
-
});
|
|
1241
|
+
}, { immediate: true });
|
|
1242
1242
|
|
|
1243
1243
|
onMounted(async () => {
|
|
1244
1244
|
try {
|
|
@@ -1478,7 +1478,7 @@ onUnmounted(() => {
|
|
|
1478
1478
|
:device-actions="deviceActions"
|
|
1479
1479
|
:playground-available="playgroundAvailable"
|
|
1480
1480
|
:playground-device-id="playgroundDeviceId"
|
|
1481
|
-
:playground-frame-url="''"
|
|
1481
|
+
:playground-frame-url="activeMenu === 'appium' ? playgroundFrameUrl : ''"
|
|
1482
1482
|
:playground-preview-error="playgroundPreviewError"
|
|
1483
1483
|
:device-preview-url="activeMenu === 'appium' ? devicePreviewUrl : ''"
|
|
1484
1484
|
:android-devices="androidDevices"
|