android-midscene-automation 0.1.0

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 (78) hide show
  1. package/README.md +160 -0
  2. package/bin/android-midscene-automation.js +27 -0
  3. package/index.html +12 -0
  4. package/package.json +49 -0
  5. package/remote-agent/index.ts +206 -0
  6. package/server/appium-recorder/appium-runner.ts +427 -0
  7. package/server/appium-recorder/repository.ts +228 -0
  8. package/server/appium-recorder/routes.ts +219 -0
  9. package/server/config-store.ts +167 -0
  10. package/server/config.ts +130 -0
  11. package/server/device-locks/repository.ts +147 -0
  12. package/server/device-locks/service.ts +72 -0
  13. package/server/device-locks/types.ts +22 -0
  14. package/server/device-sessions/repository.ts +169 -0
  15. package/server/device-sessions/service.ts +59 -0
  16. package/server/device-sessions/types.ts +24 -0
  17. package/server/http-api.ts +1389 -0
  18. package/server/model-call-usage-importer.ts +108 -0
  19. package/server/model-tester.ts +104 -0
  20. package/server/model-usage-repository.ts +131 -0
  21. package/server/operations/repository.ts +218 -0
  22. package/server/operations/service.ts +84 -0
  23. package/server/operations/types.ts +27 -0
  24. package/server/paths.ts +27 -0
  25. package/server/remote-agents/protocol.ts +38 -0
  26. package/server/remote-agents/registry.ts +136 -0
  27. package/server/remote-agents/routes.ts +89 -0
  28. package/server/script-agent.ts +284 -0
  29. package/server/script-db.ts +281 -0
  30. package/server/script-runner.ts +551 -0
  31. package/server/storage/sqlite.ts +49 -0
  32. package/server/test-case-import/formatter.ts +28 -0
  33. package/server/test-case-import/parsers/excel.ts +69 -0
  34. package/server/test-case-import/parsers/txt.ts +11 -0
  35. package/server/test-case-import/parsers/word.ts +9 -0
  36. package/server/test-case-import/service.ts +58 -0
  37. package/server/test-case-import/text-normalizer.ts +98 -0
  38. package/server/test-case-import/types.ts +24 -0
  39. package/server/test-case-import/validator.ts +34 -0
  40. package/src/App.vue +1450 -0
  41. package/src/api.ts +290 -0
  42. package/src/appium-recorder/AppiumPage.vue +894 -0
  43. package/src/appium-recorder/api.ts +64 -0
  44. package/src/appium-recorder/components/ComponentTree.vue +44 -0
  45. package/src/appium-recorder/components/NodeDetail.vue +152 -0
  46. package/src/appium-recorder/components/RecordedSteps.vue +79 -0
  47. package/src/appium-recorder/tree.ts +129 -0
  48. package/src/appium-recorder/types.ts +88 -0
  49. package/src/assets/device-actions/back.svg +5 -0
  50. package/src/assets/device-actions/home.svg +3 -0
  51. package/src/assets/device-actions/power.svg +5 -0
  52. package/src/assets/device-actions/tasks.svg +3 -0
  53. package/src/assets/device-actions/volume-down.svg +3 -0
  54. package/src/assets/device-actions/volume-up.svg +3 -0
  55. package/src/components/config/ModelUsageChart.vue +188 -0
  56. package/src/components/device/DevicePreviewPanel.vue +266 -0
  57. package/src/components/generator/GeneratedCodePanel.vue +70 -0
  58. package/src/components/generator/TestCaseFileUpload.vue +97 -0
  59. package/src/config/midscene-model-presets.ts +75 -0
  60. package/src/config/prompt-example.ts +6 -0
  61. package/src/main.ts +7 -0
  62. package/src/pages/AiGeneratorPage.vue +90 -0
  63. package/src/pages/AutomationPage.vue +161 -0
  64. package/src/pages/ConfigPage.vue +273 -0
  65. package/src/pages/GeneratorPage.vue +97 -0
  66. package/src/pages/ManualStepsPage.vue +179 -0
  67. package/src/script-generator/codegen.ts +126 -0
  68. package/src/script-generator/index.ts +4 -0
  69. package/src/script-generator/presets.ts +37 -0
  70. package/src/script-generator/step-options.ts +52 -0
  71. package/src/script-generator/types.ts +27 -0
  72. package/src/style.css +1983 -0
  73. package/src/types.ts +157 -0
  74. package/src/vite-env.d.ts +1 -0
  75. package/tsconfig.app.json +8 -0
  76. package/tsconfig.json +11 -0
  77. package/tsconfig.node.json +16 -0
  78. package/vite.config.ts +28 -0
@@ -0,0 +1,427 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { mkdir, writeFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import type { AppiumRecordedScriptRecord, AppiumRecordedStepRecord } from './repository';
5
+
6
+ type AppiumSessionResponse = {
7
+ value?: {
8
+ sessionId?: string;
9
+ capabilities?: unknown;
10
+ };
11
+ sessionId?: string;
12
+ };
13
+
14
+ type AppiumElementResponse = {
15
+ value?: Record<string, string>;
16
+ };
17
+
18
+ type AppiumValueResponse<T> = {
19
+ value?: T;
20
+ };
21
+
22
+ const ELEMENT_KEY = 'element-6066-11e4-a52e-4f735466cecf';
23
+ const appiumServerUrl = () => (process.env.APPIUM_SERVER_URL || 'http://127.0.0.1:4723').replace(/\/+$/, '');
24
+
25
+ function errorDetail(error: unknown) {
26
+ if (!(error instanceof Error)) return String(error || '未知错误');
27
+ const cause = error.cause;
28
+ if (cause instanceof Error && cause.message && cause.message !== error.message) {
29
+ return `${error.message}:${cause.message}`;
30
+ }
31
+ return error.message;
32
+ }
33
+
34
+ function isStaleElementError(error: unknown) {
35
+ const detail = errorDetail(error);
36
+ return detail.includes('stale element reference') || detail.includes('does not exist in DOM anymore');
37
+ }
38
+
39
+ function wait(ms: number) {
40
+ return new Promise((resolve) => setTimeout(resolve, ms));
41
+ }
42
+
43
+ function adbTap(deviceId: string, x: number, y: number) {
44
+ return new Promise<void>((resolve, reject) => {
45
+ execFile('adb', ['-s', deviceId, 'shell', 'input', 'tap', String(Math.round(x)), String(Math.round(y))], (error, _stdout, stderr) => {
46
+ if (error) {
47
+ reject(new Error(stderr || error.message));
48
+ return;
49
+ }
50
+ resolve();
51
+ });
52
+ });
53
+ }
54
+
55
+ function adbSwipe(deviceId: string, step: Required<AppiumRecordedStepRecord>['swipe']) {
56
+ return new Promise<void>((resolve, reject) => {
57
+ execFile('adb', [
58
+ '-s',
59
+ deviceId,
60
+ 'shell',
61
+ 'input',
62
+ 'swipe',
63
+ String(Math.round(step.startX)),
64
+ String(Math.round(step.startY)),
65
+ String(Math.round(step.endX)),
66
+ String(Math.round(step.endY)),
67
+ String(Math.round(step.duration)),
68
+ ], (error, _stdout, stderr) => {
69
+ if (error) {
70
+ reject(new Error(stderr || error.message));
71
+ return;
72
+ }
73
+ resolve();
74
+ });
75
+ });
76
+ }
77
+
78
+ function adbText(deviceId: string, args: string[]) {
79
+ return new Promise<string>((resolve) => {
80
+ execFile('adb', ['-s', deviceId, ...args], { maxBuffer: 4 * 1024 * 1024 }, (_error, stdout, stderr) => {
81
+ resolve((stdout || stderr || '').trim());
82
+ });
83
+ });
84
+ }
85
+
86
+ async function getCurrentActivity(deviceId: string) {
87
+ const output = await adbText(deviceId, ['shell', 'dumpsys', 'activity', 'activities']);
88
+ const resumedLine = output
89
+ .split(/\r?\n/)
90
+ .find((line) => /(?:topResumedActivity|ResumedActivity|mResumedActivity)/.test(line));
91
+ return resumedLine?.match(/\s([\w.$]+\/[\w.$]+)\s/)?.[1] || '';
92
+ }
93
+
94
+ async function waitForActivity(deviceId: string, step: AppiumRecordedStepRecord) {
95
+ const expectedActivity = step.value || '';
96
+ if (!expectedActivity) throw new Error(`${step.label} 缺少目标 Activity`);
97
+ const startedAt = Date.now();
98
+ const timeoutMs = step.timeoutMs || 10000;
99
+ let currentActivity = '';
100
+ while (Date.now() - startedAt <= timeoutMs) {
101
+ currentActivity = await getCurrentActivity(deviceId);
102
+ if (currentActivity === expectedActivity) return;
103
+ await wait(500);
104
+ }
105
+ throw new Error(`${step.label} 等待超时,当前 Activity:${currentActivity || '-'}`);
106
+ }
107
+
108
+ async function appendSettingsDiagnostics(lines: string[], deviceId: string) {
109
+ const packageInfo = await adbText(deviceId, ['shell', 'dumpsys', 'package', 'io.appium.settings']);
110
+ const launchInfo = await adbText(deviceId, ['shell', 'am', 'start-activity', '-n', 'io.appium.settings/.Settings', '-a', 'android.intent.action.MAIN', '-c', 'android.intent.category.LAUNCHER']);
111
+ await new Promise((resolve) => setTimeout(resolve, 1000));
112
+ const serviceInfo = await adbText(deviceId, ['shell', 'dumpsys', 'activity', 'services', 'io.appium.settings']);
113
+ lines.push('Appium Settings 诊断:');
114
+ lines.push(`- 安装状态:${packageInfo.includes('Package [io.appium.settings]') ? '已安装' : '未检测到 io.appium.settings'}`);
115
+ lines.push(`- 手动启动:${launchInfo || '-'}`);
116
+ lines.push(`- 服务状态:${serviceInfo || '-'}`);
117
+ }
118
+
119
+ async function appiumRequest<T>(path: string, init?: RequestInit) {
120
+ const requestUrl = `${appiumServerUrl()}${path}`;
121
+ let response: Response;
122
+ try {
123
+ response = await fetch(requestUrl, {
124
+ ...init,
125
+ headers: {
126
+ 'Content-Type': 'application/json',
127
+ ...(init?.headers || {}),
128
+ },
129
+ });
130
+ } catch (error) {
131
+ throw new Error(`无法连接 Appium 服务 ${requestUrl},${errorDetail(error)}`);
132
+ }
133
+
134
+ const responseText = await response.text();
135
+ const payload = (() => {
136
+ try {
137
+ return responseText ? JSON.parse(responseText) : {};
138
+ } catch {
139
+ return {};
140
+ }
141
+ })() as T & {
142
+ value?: { error?: string; message?: string; stacktrace?: string };
143
+ error?: string;
144
+ message?: string;
145
+ stacktrace?: string;
146
+ };
147
+ if (!response.ok) {
148
+ const detail = [
149
+ payload.value?.error || payload.error ? `错误类型:${payload.value?.error || payload.error}` : '',
150
+ payload.value?.message || payload.message ? `错误信息:${payload.value?.message || payload.message}` : '',
151
+ payload.value?.stacktrace || payload.stacktrace ? `堆栈信息:\n${payload.value?.stacktrace || payload.stacktrace}` : '',
152
+ responseText ? `原始响应:\n${responseText}` : '',
153
+ ].filter(Boolean).join('\n');
154
+ throw new Error(`Appium ${init?.method || 'GET'} ${path} 失败(HTTP ${response.status}):\n${detail || '未返回错误详情'}`);
155
+ }
156
+ return payload;
157
+ }
158
+
159
+ async function createSession(script: AppiumRecordedScriptRecord, deviceId: string) {
160
+ const payload = await appiumRequest<AppiumSessionResponse>('/session', {
161
+ method: 'POST',
162
+ body: JSON.stringify({
163
+ capabilities: {
164
+ alwaysMatch: {
165
+ platformName: 'Android',
166
+ 'appium:automationName': 'UiAutomator2',
167
+ 'appium:udid': deviceId,
168
+ 'appium:appPackage': script.appPackage,
169
+ ...(script.appActivity ? { 'appium:appActivity': script.appActivity } : {}),
170
+ 'appium:noReset': true,
171
+ 'appium:skipDeviceInitialization': true,
172
+ 'appium:ignoreHiddenApiPolicyError': true,
173
+ },
174
+ },
175
+ }),
176
+ });
177
+ const sessionId = payload.value?.sessionId || payload.sessionId || '';
178
+ if (!sessionId) throw new Error('Appium 未返回 sessionId');
179
+ return sessionId;
180
+ }
181
+
182
+ function toAppiumUsing(step: AppiumRecordedStepRecord) {
183
+ const selector = step.selector;
184
+ if (!selector) throw new Error(`${step.label} 缺少 selector`);
185
+ if (selector.strategy === 'accessibilityId') return { using: 'accessibility id', value: selector.value || '' };
186
+ if (selector.strategy === 'id') return { using: 'id', value: selector.value || '' };
187
+ if (selector.strategy === 'androidUiAutomator') return { using: '-android uiautomator', value: selector.value || '' };
188
+ if (selector.strategy === 'xpath') return { using: 'xpath', value: selector.value || '' };
189
+ throw new Error(`${step.label} 需要使用 bounds 坐标执行`);
190
+ }
191
+
192
+ async function findElement(sessionId: string, step: AppiumRecordedStepRecord) {
193
+ const using = toAppiumUsing(step);
194
+ const payload = await appiumRequest<AppiumElementResponse>(`/session/${sessionId}/element`, {
195
+ method: 'POST',
196
+ body: JSON.stringify(using),
197
+ });
198
+ const elementId = payload.value?.[ELEMENT_KEY] || payload.value?.ELEMENT || '';
199
+ if (!elementId) throw new Error(`${step.label} 未找到元素`);
200
+ return elementId;
201
+ }
202
+
203
+ async function tapFallback(deviceId: string, step: AppiumRecordedStepRecord) {
204
+ if (step.fallback?.strategy !== 'bounds' || !Number.isFinite(step.fallback.centerX) || !Number.isFinite(step.fallback.centerY)) {
205
+ throw new Error(`${step.label} 未找到元素,且没有可用坐标兜底`);
206
+ }
207
+ await adbTap(deviceId, Number(step.fallback.centerX), Number(step.fallback.centerY));
208
+ }
209
+
210
+ async function waitForElement(sessionId: string, step: AppiumRecordedStepRecord) {
211
+ const startedAt = Date.now();
212
+ const timeoutMs = step.timeoutMs || 10000;
213
+ let lastError: unknown;
214
+ while (Date.now() - startedAt <= timeoutMs) {
215
+ try {
216
+ await findElement(sessionId, step);
217
+ return;
218
+ } catch (error) {
219
+ lastError = error;
220
+ await wait(500);
221
+ }
222
+ }
223
+ throw lastError instanceof Error ? lastError : new Error(`${step.label} 等待超时`);
224
+ }
225
+
226
+ async function waitForElementGone(sessionId: string, step: AppiumRecordedStepRecord) {
227
+ const startedAt = Date.now();
228
+ const timeoutMs = step.timeoutMs || 10000;
229
+ while (Date.now() - startedAt <= timeoutMs) {
230
+ try {
231
+ await findElement(sessionId, step);
232
+ } catch {
233
+ return;
234
+ }
235
+ await wait(500);
236
+ }
237
+ throw new Error(`${step.label} 等待消失超时`);
238
+ }
239
+
240
+ async function saveScreenshot(sessionId: string) {
241
+ const payload = await appiumRequest<AppiumValueResponse<string>>(`/session/${sessionId}/screenshot`);
242
+ if (!payload.value) throw new Error('Appium 未返回截图数据');
243
+ const dir = join(process.cwd(), '.midscene-app', 'screenshots');
244
+ await mkdir(dir, { recursive: true });
245
+ const file = join(dir, `appium-${Date.now()}.png`);
246
+ await writeFile(file, Buffer.from(payload.value, 'base64'));
247
+ }
248
+
249
+ async function runStep(sessionId: string, deviceId: string, step: AppiumRecordedStepRecord) {
250
+ if (step.type === 'delay') {
251
+ await wait(Math.max(0, step.timeoutMs || 1000));
252
+ return;
253
+ }
254
+
255
+ if (step.type === 'waitActivity') {
256
+ await waitForActivity(deviceId, step);
257
+ return;
258
+ }
259
+
260
+ if (step.type === 'key') {
261
+ await appiumRequest(`/session/${sessionId}/appium/device/press_keycode`, {
262
+ method: 'POST',
263
+ body: JSON.stringify({ keycode: step.keyCode || 4 }),
264
+ });
265
+ return;
266
+ }
267
+
268
+ if (step.type === 'launchApp') {
269
+ await appiumRequest(`/session/${sessionId}/appium/device/activate_app`, {
270
+ method: 'POST',
271
+ body: JSON.stringify({ appId: step.value }),
272
+ });
273
+ return;
274
+ }
275
+
276
+ if (step.type === 'screenshot') {
277
+ await saveScreenshot(sessionId);
278
+ return;
279
+ }
280
+
281
+ if (step.type === 'coordinateTap') {
282
+ await tapFallback(deviceId, step);
283
+ return;
284
+ }
285
+
286
+ if (step.type === 'swipe') {
287
+ if (!step.swipe) throw new Error(`${step.label} 缺少滑动坐标`);
288
+ await adbSwipe(deviceId, step.swipe);
289
+ return;
290
+ }
291
+
292
+ if (step.type === 'longPress') {
293
+ if (step.fallback?.strategy !== 'bounds' || !Number.isFinite(step.fallback.centerX) || !Number.isFinite(step.fallback.centerY)) {
294
+ throw new Error(`${step.label} 缺少长按坐标`);
295
+ }
296
+ const x = Number(step.fallback.centerX);
297
+ const y = Number(step.fallback.centerY);
298
+ await adbSwipe(deviceId, { startX: x, startY: y, endX: x, endY: y, duration: step.timeoutMs || 800 });
299
+ return;
300
+ }
301
+
302
+ if (step.type === 'pinch') {
303
+ if (!step.pinch) throw new Error(`${step.label} 缺少缩放参数`);
304
+ const size = Math.round(Math.min(500, Math.max(120, step.pinch.centerX, step.pinch.centerY)));
305
+ await appiumRequest(`/session/${sessionId}/execute/sync`, {
306
+ method: 'POST',
307
+ body: JSON.stringify({
308
+ script: step.pinch.direction === 'out' ? 'mobile: pinchOpenGesture' : 'mobile: pinchCloseGesture',
309
+ args: [{
310
+ left: Math.max(0, Math.round(step.pinch.centerX - size / 2)),
311
+ top: Math.max(0, Math.round(step.pinch.centerY - size / 2)),
312
+ width: size,
313
+ height: size,
314
+ percent: step.pinch.percent,
315
+ }],
316
+ }),
317
+ });
318
+ return;
319
+ }
320
+
321
+ if (step.type === 'waitDisappear') {
322
+ await waitForElementGone(sessionId, step);
323
+ return;
324
+ }
325
+
326
+ if (step.type === 'waitFor' || step.type === 'assertExists') {
327
+ await waitForElement(sessionId, step);
328
+ return;
329
+ }
330
+
331
+ if (step.type === 'assertText') {
332
+ const elementId = await findElement(sessionId, step);
333
+ const payload = await appiumRequest<AppiumValueResponse<string>>(`/session/${sessionId}/element/${elementId}/text`);
334
+ const actual = payload.value || '';
335
+ if (!actual.includes(step.value || '')) throw new Error(`${step.label} 不匹配,实际文本:${actual || '-'}`);
336
+ return;
337
+ }
338
+
339
+ if (step.type === 'tap') {
340
+ for (let attempt = 0; attempt < 2; attempt += 1) {
341
+ try {
342
+ const elementId = await findElement(sessionId, step);
343
+ await appiumRequest(`/session/${sessionId}/element/${elementId}/click`, {
344
+ method: 'POST',
345
+ body: JSON.stringify({}),
346
+ });
347
+ return;
348
+ } catch (error) {
349
+ if (attempt === 0 && isStaleElementError(error)) {
350
+ await wait(300);
351
+ continue;
352
+ }
353
+ await tapFallback(deviceId, step);
354
+ return;
355
+ }
356
+ }
357
+ return;
358
+ }
359
+
360
+ if (step.type === 'input') {
361
+ for (let attempt = 0; attempt < 2; attempt += 1) {
362
+ try {
363
+ const elementId = await findElement(sessionId, step);
364
+ await appiumRequest(`/session/${sessionId}/element/${elementId}/value`, {
365
+ method: 'POST',
366
+ body: JSON.stringify({ text: step.value || '', value: [...(step.value || '')] }),
367
+ });
368
+ return;
369
+ } catch (error) {
370
+ if (attempt === 0 && isStaleElementError(error)) {
371
+ await wait(300);
372
+ continue;
373
+ }
374
+ throw error;
375
+ }
376
+ }
377
+ }
378
+
379
+ if (step.type === 'clearInput') {
380
+ const elementId = await findElement(sessionId, step);
381
+ await appiumRequest(`/session/${sessionId}/element/${elementId}/clear`, {
382
+ method: 'POST',
383
+ body: JSON.stringify({}),
384
+ });
385
+ }
386
+ }
387
+
388
+ export async function replayAppiumScript(script: AppiumRecordedScriptRecord, deviceId: string) {
389
+ const targetDeviceId = deviceId || script.deviceId;
390
+ if (!targetDeviceId) throw new Error('未检测到可用设备');
391
+ if (!script.steps.length) throw new Error('脚本没有可回放步骤');
392
+
393
+ const lines: string[] = [
394
+ `Appium 服务:${appiumServerUrl()}`,
395
+ `目标设备:${targetDeviceId}`,
396
+ `App 包名:${script.appPackage}`,
397
+ `录制步骤:${script.steps.length}`,
398
+ ];
399
+ let sessionId = '';
400
+ try {
401
+ lines.push('正在创建 Appium session...');
402
+ sessionId = await createSession(script, targetDeviceId);
403
+ lines.push(`Appium session 已创建:${sessionId}`);
404
+ for (const [index, step] of script.steps.entries()) {
405
+ lines.push(`[步骤 ${index + 1}] 开始:${step.label}`);
406
+ try {
407
+ await runStep(sessionId, targetDeviceId, step);
408
+ lines.push(`[步骤 ${index + 1}] 完成:${step.label}`);
409
+ } catch (error) {
410
+ lines.push(`[步骤 ${index + 1}] 失败:${errorDetail(error)}`);
411
+ throw error;
412
+ }
413
+ }
414
+ lines.push('回放完成');
415
+ return { success: true, output: lines.join('\n') };
416
+ } catch (error) {
417
+ if (errorDetail(error).includes('Appium Settings app is not running')) {
418
+ await appendSettingsDiagnostics(lines, targetDeviceId);
419
+ }
420
+ lines.push(`回放终止:${errorDetail(error)}`);
421
+ return { success: false, output: lines.join('\n') };
422
+ } finally {
423
+ if (sessionId) {
424
+ await appiumRequest(`/session/${sessionId}`, { method: 'DELETE' }).catch(() => undefined);
425
+ }
426
+ }
427
+ }
@@ -0,0 +1,228 @@
1
+ import {
2
+ createId,
3
+ querySql,
4
+ runSql,
5
+ sqlJson,
6
+ sqlNullableString,
7
+ sqlString,
8
+ } from '../storage/sqlite';
9
+
10
+ export type AppiumRecordedStepRecord = {
11
+ id: string;
12
+ type:
13
+ | 'tap'
14
+ | 'input'
15
+ | 'waitFor'
16
+ | 'assertExists'
17
+ | 'key'
18
+ | 'waitActivity'
19
+ | 'delay'
20
+ | 'clearInput'
21
+ | 'coordinateTap'
22
+ | 'swipe'
23
+ | 'screenshot'
24
+ | 'launchApp'
25
+ | 'waitDisappear'
26
+ | 'assertText'
27
+ | 'longPress'
28
+ | 'pinch';
29
+ label: string;
30
+ selector?: {
31
+ strategy: 'accessibilityId' | 'id' | 'androidUiAutomator' | 'xpath' | 'bounds';
32
+ value?: string;
33
+ centerX?: number;
34
+ centerY?: number;
35
+ };
36
+ fallback?: {
37
+ strategy: 'accessibilityId' | 'id' | 'androidUiAutomator' | 'xpath' | 'bounds';
38
+ value?: string;
39
+ centerX?: number;
40
+ centerY?: number;
41
+ };
42
+ value?: string;
43
+ keyCode?: number;
44
+ timeoutMs?: number;
45
+ swipe?: {
46
+ startX: number;
47
+ startY: number;
48
+ endX: number;
49
+ endY: number;
50
+ duration: number;
51
+ };
52
+ pinch?: {
53
+ direction: 'in' | 'out';
54
+ centerX: number;
55
+ centerY: number;
56
+ percent: number;
57
+ };
58
+ snapshot?: {
59
+ text: string;
60
+ resourceId: string;
61
+ contentDesc: string;
62
+ className: string;
63
+ };
64
+ };
65
+
66
+ export type AppiumRecordedScriptRecord = {
67
+ id: string;
68
+ name: string;
69
+ appPackage: string;
70
+ appActivity: string;
71
+ deviceId: string;
72
+ steps: AppiumRecordedStepRecord[];
73
+ createdAt: string;
74
+ updatedAt: string;
75
+ };
76
+
77
+ type AppiumRecordedScriptRow = {
78
+ id: string;
79
+ name: string;
80
+ app_package: string;
81
+ app_activity: string | null;
82
+ device_id: string | null;
83
+ steps_json: string;
84
+ created_at: string;
85
+ updated_at: string;
86
+ };
87
+
88
+ let initialized = false;
89
+
90
+ function initDb() {
91
+ if (initialized) return;
92
+ initialized = true;
93
+ runSql(`
94
+ CREATE TABLE IF NOT EXISTS appium_recorded_scripts (
95
+ id TEXT PRIMARY KEY,
96
+ name TEXT NOT NULL UNIQUE,
97
+ app_package TEXT NOT NULL,
98
+ app_activity TEXT,
99
+ device_id TEXT,
100
+ steps_json TEXT NOT NULL DEFAULT '[]',
101
+ created_at TEXT NOT NULL,
102
+ updated_at TEXT NOT NULL
103
+ );
104
+ CREATE INDEX IF NOT EXISTS idx_appium_recorded_scripts_updated_at
105
+ ON appium_recorded_scripts(updated_at DESC);
106
+ `);
107
+ }
108
+
109
+ function rowToRecord(row: AppiumRecordedScriptRow): AppiumRecordedScriptRecord {
110
+ return {
111
+ id: row.id,
112
+ name: row.name,
113
+ appPackage: row.app_package,
114
+ appActivity: row.app_activity || '',
115
+ deviceId: row.device_id || '',
116
+ steps: JSON.parse(row.steps_json || '[]') as AppiumRecordedStepRecord[],
117
+ createdAt: row.created_at,
118
+ updatedAt: row.updated_at,
119
+ };
120
+ }
121
+
122
+ export function listAppiumRecordedScripts() {
123
+ initDb();
124
+ return querySql<AppiumRecordedScriptRow>(`
125
+ SELECT
126
+ id,
127
+ name,
128
+ app_package,
129
+ app_activity,
130
+ device_id,
131
+ steps_json,
132
+ created_at,
133
+ updated_at
134
+ FROM appium_recorded_scripts
135
+ ORDER BY updated_at DESC;
136
+ `).map(rowToRecord);
137
+ }
138
+
139
+ export function getAppiumRecordedScript(id: string) {
140
+ initDb();
141
+ const row = querySql<AppiumRecordedScriptRow>(`
142
+ SELECT
143
+ id,
144
+ name,
145
+ app_package,
146
+ app_activity,
147
+ device_id,
148
+ steps_json,
149
+ created_at,
150
+ updated_at
151
+ FROM appium_recorded_scripts
152
+ WHERE id = ${sqlString(id)}
153
+ LIMIT 1;
154
+ `)[0];
155
+ return row ? rowToRecord(row) : null;
156
+ }
157
+
158
+ export function saveAppiumRecordedScript(input: {
159
+ id?: string;
160
+ name: string;
161
+ appPackage: string;
162
+ appActivity?: string;
163
+ deviceId?: string;
164
+ steps: AppiumRecordedStepRecord[];
165
+ }) {
166
+ initDb();
167
+ const now = new Date().toISOString();
168
+ const id = input.id || createId('appium_script');
169
+ const name = input.name.trim();
170
+ const appPackage = input.appPackage.trim();
171
+
172
+ if (!name) throw new Error('脚本名称不能为空');
173
+ if (!appPackage) throw new Error('App 包名不能为空');
174
+
175
+ if (input.id && getAppiumRecordedScript(input.id)) {
176
+ runSql(`
177
+ UPDATE appium_recorded_scripts
178
+ SET
179
+ name = ${sqlString(name)},
180
+ app_package = ${sqlString(appPackage)},
181
+ app_activity = ${sqlNullableString(input.appActivity || '')},
182
+ device_id = ${sqlNullableString(input.deviceId || '')},
183
+ steps_json = ${sqlJson(input.steps || [])},
184
+ updated_at = ${sqlString(now)}
185
+ WHERE id = ${sqlString(input.id)};
186
+ `);
187
+ return getAppiumRecordedScript(input.id);
188
+ }
189
+
190
+ runSql(`
191
+ INSERT INTO appium_recorded_scripts (
192
+ id,
193
+ name,
194
+ app_package,
195
+ app_activity,
196
+ device_id,
197
+ steps_json,
198
+ created_at,
199
+ updated_at
200
+ )
201
+ VALUES (
202
+ ${sqlString(id)},
203
+ ${sqlString(name)},
204
+ ${sqlString(appPackage)},
205
+ ${sqlNullableString(input.appActivity || '')},
206
+ ${sqlNullableString(input.deviceId || '')},
207
+ ${sqlJson(input.steps || [])},
208
+ ${sqlString(now)},
209
+ ${sqlString(now)}
210
+ )
211
+ ON CONFLICT(name) DO UPDATE SET
212
+ app_package = excluded.app_package,
213
+ app_activity = excluded.app_activity,
214
+ device_id = excluded.device_id,
215
+ steps_json = excluded.steps_json,
216
+ updated_at = excluded.updated_at;
217
+ `);
218
+
219
+ return listAppiumRecordedScripts().find((script) => script.name === name) || getAppiumRecordedScript(id);
220
+ }
221
+
222
+ export function deleteAppiumRecordedScript(id: string) {
223
+ initDb();
224
+ runSql(`
225
+ DELETE FROM appium_recorded_scripts
226
+ WHERE id = ${sqlString(id)};
227
+ `);
228
+ }