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
package/README.md ADDED
@@ -0,0 +1,160 @@
1
+ # Midscene Playwright Admin
2
+
3
+ 一个最小可用的 Midscene + Playwright 项目,同时提供一个基于 Vue 3 和 Element Plus 的后台,用来把自然语言 prompt 生成结构化自动化脚本。
4
+
5
+ ## 环境要求
6
+
7
+ - Node.js 18+
8
+ - npm
9
+ - Android SDK / ADB,移动端自动化测试需要
10
+ - Playwright Chromium,Web 示例脚本需要
11
+
12
+ ## 一行启动
13
+
14
+ ```sh
15
+ npx android-midscene-automation
16
+ ```
17
+
18
+ 启动后访问:
19
+
20
+ ```text
21
+ http://127.0.0.1:5173/
22
+ ```
23
+
24
+ 如果还没有发布到 npm,也可以用 GitHub 地址运行:
25
+
26
+ ```sh
27
+ npx github:你的组织/你的仓库
28
+ ```
29
+
30
+ 每个测试人员在自己的电脑运行这条命令,后端执行的就是当前电脑上的 `adb devices`,页面会识别当前电脑连接的手机。
31
+
32
+ 如果 `5173` 端口被占用,可以指定端口:
33
+
34
+ ```sh
35
+ npx android-midscene-automation --port 5174
36
+ ```
37
+
38
+ ## 源码开发
39
+
40
+ ```sh
41
+ npm install
42
+ npm run dev
43
+ ```
44
+
45
+ `Playwright` 浏览器下载不走 npm registry。单独安装浏览器时,可临时指定镜像:
46
+
47
+ ```sh
48
+ PLAYWRIGHT_DOWNLOAD_HOST=https://npmmirror.com/mirrors/playwright/ npx playwright install chromium
49
+ ```
50
+
51
+ ## 局域网访问
52
+
53
+ ```sh
54
+ npm run dev
55
+ ```
56
+
57
+ 启动后访问 Vite 输出的地址。项目默认监听 `0.0.0.0`,支持局域网访问:
58
+
59
+ ```text
60
+ http://127.0.0.1:5173/
61
+ http://你的局域网IP:5173/
62
+ ```
63
+
64
+ `npm run dev` 会同时启动前端页面和本地后端 API。后端不是独立进程,而是在 `vite.config.ts` 中通过 `server/http-api.ts` 挂载到 Vite dev server 的 Connect middleware。
65
+
66
+ ## 模型配置
67
+
68
+ 首次启动后进入页面的“参数配置”:
69
+
70
+ - `Midscene模型`:执行测试脚本时使用,支持“自定义提供方”和“使用 Codex”。
71
+ - `AI生成脚本模型`:根据测试需求生成脚本时使用。
72
+
73
+ 配置会保存到本地 SQLite:
74
+
75
+ ```text
76
+ .midscene-app/script-cache.sqlite
77
+ ```
78
+
79
+ 旧版 `config.yaml` 或 `config.json` 会在读取后迁移到数据库。当前运行时会优先读取数据库中的模型配置。
80
+
81
+ ## 后端说明
82
+
83
+ 本项目的本地后端位于 `server/http-api.ts`,通过 `/api/*` 暴露能力,主要包括:
84
+
85
+ - 脚本生成:`POST /api/generate`
86
+ - 脚本保存和列表:`POST /api/save-script`、`GET /api/scripts`
87
+ - 脚本执行和停止:`POST /api/run-script`、`POST /api/stop-script`
88
+ - 模型配置:`GET /api/config`、`POST /api/config`
89
+ - 模型测试和消耗统计:`POST /api/test-model`、`GET /api/model-usage-records`
90
+ - Android 设备:`GET /api/android-devices`、`POST /api/android-device`
91
+ - Android 预览和操作:`GET /api/android-preview`、`POST /api/android-tap`、`POST /api/android-swipe`、`POST /api/android-keyevent`
92
+ - App 预设:`GET /api/app-presets`、`POST /api/save-app-preset`、`POST /api/delete-app-preset`
93
+
94
+ 后端运行态文件默认都在项目根目录下:
95
+
96
+ ```text
97
+ .midscene-app/ # SQLite 数据库
98
+ .midscene-generated/ # 执行前生成的临时脚本
99
+ scripts-output/ # 保存的脚本输出
100
+ midscene_run/ # Midscene 执行报告和运行产物
101
+ ```
102
+
103
+ ## Android 自动化
104
+
105
+ 执行移动端脚本前确认设备已连接:
106
+
107
+ ```sh
108
+ adb devices -l
109
+ ```
110
+
111
+ 页面会通过 `/api/android-devices` 获取设备列表。执行脚本时会把当前选择的设备 ID 传给后端,由 `@midscene/android` 连接设备并运行生成脚本。
112
+
113
+ 如果要走不依赖大模型的 Appium 组件树录制方案,源码仓库里的 `demo` 目录保留了方案文档;npm 包不会发布 `demo` 目录。
114
+
115
+ 如果测试人员需要识别自己电脑上的手机,推荐每个人在自己的电脑本地运行:
116
+
117
+ ```sh
118
+ npx android-midscene-automation
119
+ ```
120
+
121
+ 然后访问自己本机的:
122
+
123
+ ```text
124
+ http://localhost:5173/
125
+ ```
126
+
127
+ 这样后端执行的是当前电脑上的 `adb devices`,页面会识别当前电脑连接的手机。
128
+
129
+ 局域网共享页面只适合查看同一个中央服务,不适合直接识别访问者电脑上的 ADB 设备。远程设备代理方案仅保留在源码仓库的 `demo` 目录中,不随 npm 包发布。
130
+
131
+ 如果要回放 Appium 脚本,本机还需要启动 Appium:
132
+
133
+ ```sh
134
+ appium
135
+ ```
136
+ 如果要使用远程设备代理实验方案,可以在访问者电脑的项目目录中运行:
137
+
138
+ ```sh
139
+ npm run remote-agent -- --server http://中央服务IP:5173 --agent-id 你的名字或机器名
140
+ ```
141
+
142
+ ## 运行 E2E 测试
143
+
144
+ ```sh
145
+ npm run test:e2e
146
+ ```
147
+
148
+ ## 构建
149
+
150
+ ```sh
151
+ npm run build
152
+ ```
153
+
154
+ ## 本地预览构建结果
155
+
156
+ ```sh
157
+ npm run preview
158
+ ```
159
+
160
+ `npm run preview` 同样会挂载 `/api/*` 后端 middleware。
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from 'node:child_process';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
7
+ const viteBin = path.join(packageRoot, 'node_modules', 'vite', 'bin', 'vite.js');
8
+ const args = process.argv.slice(2);
9
+
10
+ if (!args.includes('--host')) {
11
+ args.unshift('127.0.0.1');
12
+ args.unshift('--host');
13
+ }
14
+
15
+ const child = spawn(process.execPath, [viteBin, ...args], {
16
+ cwd: packageRoot,
17
+ env: process.env,
18
+ stdio: 'inherit',
19
+ });
20
+
21
+ child.on('exit', (code, signal) => {
22
+ if (signal) {
23
+ process.kill(process.pid, signal);
24
+ return;
25
+ }
26
+ process.exit(code ?? 0);
27
+ });
package/index.html ADDED
@@ -0,0 +1,12 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Midscene 脚本后台</title>
7
+ </head>
8
+ <body>
9
+ <div id="app"></div>
10
+ <script type="module" src="/src/main.ts"></script>
11
+ </body>
12
+ </html>
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "android-midscene-automation",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "bin": {
6
+ "android-midscene-automation": "./bin/android-midscene-automation.js"
7
+ },
8
+ "files": [
9
+ "bin",
10
+ "index.html",
11
+ "remote-agent",
12
+ "server",
13
+ "src",
14
+ "tsconfig*.json",
15
+ "vite.config.ts",
16
+ "README.md"
17
+ ],
18
+ "scripts": {
19
+ "dev": "vite --host 0.0.0.0",
20
+ "remote-agent": "tsx remote-agent/index.ts",
21
+ "build": "vue-tsc -b && vite build",
22
+ "preview": "vite preview --host 0.0.0.0",
23
+ "pw:install": "playwright install chromium",
24
+ "test:e2e": "playwright test"
25
+ },
26
+ "dependencies": {
27
+ "@element-plus/icons-vue": "^2.3.2",
28
+ "@midscene/android": "1.10.12",
29
+ "@midscene/web": "1.10.12",
30
+ "@vitejs/plugin-vue": "^6.0.1",
31
+ "element-plus": "^2.11.1",
32
+ "openai": "^5.12.2",
33
+ "playwright": "^1.54.1",
34
+ "tsx": "^4.20.3",
35
+ "typescript": "^5.8.3",
36
+ "vite": "^7.0.4",
37
+ "vue": "^3.5.18",
38
+ "word-extractor": "^1.0.4",
39
+ "xlsx": "^0.18.5",
40
+ "yaml": "^2.8.1"
41
+ },
42
+ "devDependencies": {
43
+ "@playwright/test": "^1.54.1",
44
+ "@types/node": "^24.0.13",
45
+ "@types/word-extractor": "^1.0.6",
46
+ "@vue/tsconfig": "^0.7.0",
47
+ "vue-tsc": "^3.0.3"
48
+ }
49
+ }
@@ -0,0 +1,206 @@
1
+ import { execFile } from 'node:child_process';
2
+ import fs from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import type { RemoteAgentDevice, RemoteCommand } from '../server/remote-agents/protocol';
6
+ import { replayAppiumScript } from '../server/appium-recorder/appium-runner';
7
+ import type { AppiumRecordedScriptRecord } from '../server/appium-recorder/repository';
8
+
9
+ const VERSION = '0.1.0';
10
+
11
+ function argValue(name: string, fallback = '') {
12
+ const index = process.argv.indexOf(`--${name}`);
13
+ return index >= 0 ? process.argv[index + 1] || fallback : fallback;
14
+ }
15
+
16
+ const server = argValue('server').replace(/\/+$/, '');
17
+ const agentId = argValue('agent-id', os.hostname()).replace(/:/g, '-');
18
+ const agentName = argValue('agent-name', agentId);
19
+ const token = argValue('token', process.env.REMOTE_AGENT_TOKEN || '');
20
+
21
+ if (!server) {
22
+ console.error('缺少 --server,例如:npm run remote-agent -- --server http://172.16.20.116:5173');
23
+ process.exit(1);
24
+ }
25
+
26
+ function execText(command: string, args: string[] = []) {
27
+ return new Promise<string>((resolve, reject) => {
28
+ execFile(command, args, { maxBuffer: 20 * 1024 * 1024 }, (error, stdout, stderr) => {
29
+ if (error) {
30
+ reject(new Error(stderr || error.message));
31
+ return;
32
+ }
33
+ resolve(stdout);
34
+ });
35
+ });
36
+ }
37
+
38
+ function execBuffer(command: string, args: string[] = []) {
39
+ return new Promise<Buffer>((resolve, reject) => {
40
+ execFile(command, args, { encoding: 'buffer', maxBuffer: 20 * 1024 * 1024 }, (error, stdout, stderr) => {
41
+ if (error) {
42
+ reject(new Error(Buffer.isBuffer(stderr) ? stderr.toString() : stderr || error.message));
43
+ return;
44
+ }
45
+ resolve(Buffer.isBuffer(stdout) ? stdout : Buffer.from(stdout));
46
+ });
47
+ });
48
+ }
49
+
50
+ async function postJson<T>(url: string, body: unknown) {
51
+ const response = await fetch(url, {
52
+ method: 'POST',
53
+ headers: { 'Content-Type': 'application/json' },
54
+ body: JSON.stringify(body),
55
+ });
56
+ const payload = await response.json().catch(() => ({})) as T & { message?: string };
57
+ if (!response.ok) throw new Error(payload.message || `HTTP ${response.status}`);
58
+ return payload;
59
+ }
60
+
61
+ async function listDevices(): Promise<RemoteAgentDevice[]> {
62
+ const output = await execText('adb', ['devices', '-l']);
63
+ return output
64
+ .split('\n')
65
+ .map((line) => line.trim())
66
+ .filter((line) => line && !line.startsWith('List of devices attached'))
67
+ .map((line) => {
68
+ const parts = line.split(/\s+/);
69
+ const id = parts[0] || '';
70
+ const status = parts[1] || 'unknown';
71
+ const model = parts.find((part) => part.startsWith('model:'))?.slice(6) || '';
72
+ const device = parts.find((part) => part.startsWith('device:'))?.slice(7) || '';
73
+ return { id, status, model, description: device || status };
74
+ })
75
+ .filter((device) => device.id);
76
+ }
77
+
78
+ async function getDisplayInfo(deviceId: string) {
79
+ const output = await execText('adb', ['-s', deviceId, 'shell', 'wm', 'size']);
80
+ const match = output.match(/Override size:\s*(\d+)x(\d+)/i) || output.match(/Physical size:\s*(\d+)x(\d+)/i);
81
+ if (!match) throw new Error('读取设备分辨率失败');
82
+ return { width: Number(match[1]), height: Number(match[2]) };
83
+ }
84
+
85
+ async function dumpWindowHierarchy(deviceId: string) {
86
+ const localPath = path.join(os.tmpdir(), `midscene-remote-agent-${deviceId.replace(/[^\w.-]/g, '_')}-${Date.now()}.xml`);
87
+ const remotePath = '/data/local/tmp/midscene_remote_agent_uidump.xml';
88
+ await execText('adb', ['-s', deviceId, 'shell', 'uiautomator', 'dump', remotePath]);
89
+ await execText('adb', ['-s', deviceId, 'pull', remotePath, localPath]);
90
+ try {
91
+ return await fs.readFile(localPath, 'utf8');
92
+ } finally {
93
+ await fs.unlink(localPath).catch(() => undefined);
94
+ }
95
+ }
96
+
97
+ async function getCurrentActivity(deviceId: string) {
98
+ const output = await execText('adb', ['-s', deviceId, 'shell', 'dumpsys', 'activity', 'activities']);
99
+ const resumedLine = output
100
+ .split(/\r?\n/)
101
+ .find((line) => /(?:topResumedActivity|ResumedActivity|mResumedActivity)/.test(line));
102
+ return resumedLine?.match(/\s([\w.$]+\/[\w.$]+)\s/)?.[1] || '';
103
+ }
104
+
105
+ async function handleCommand(command: RemoteCommand) {
106
+ const payload = command.payload || {};
107
+ if (command.type === 'screenshot') {
108
+ const image = await execBuffer('adb', ['-s', command.deviceId, 'exec-out', 'screencap', '-p']);
109
+ return { base64: image.toString('base64') };
110
+ }
111
+ if (command.type === 'displayInfo') return await getDisplayInfo(command.deviceId);
112
+ if (command.type === 'tree') {
113
+ const [xml, activity] = await Promise.all([
114
+ dumpWindowHierarchy(command.deviceId),
115
+ getCurrentActivity(command.deviceId).catch(() => ''),
116
+ ]);
117
+ return { xml, activity, dumpedAt: new Date().toISOString() };
118
+ }
119
+ if (command.type === 'tap') {
120
+ await execText('adb', ['-s', command.deviceId, 'shell', 'input', 'tap', String(Math.round(Number(payload.x))), String(Math.round(Number(payload.y)))]);
121
+ return { success: true };
122
+ }
123
+ if (command.type === 'swipe') {
124
+ await execText('adb', [
125
+ '-s',
126
+ command.deviceId,
127
+ 'shell',
128
+ 'input',
129
+ 'swipe',
130
+ String(Math.round(Number(payload.startX))),
131
+ String(Math.round(Number(payload.startY))),
132
+ String(Math.round(Number(payload.endX))),
133
+ String(Math.round(Number(payload.endY))),
134
+ String(Math.max(80, Math.round(Number(payload.duration) || 160))),
135
+ ]);
136
+ return { success: true };
137
+ }
138
+ if (command.type === 'key') {
139
+ await execText('adb', ['-s', command.deviceId, 'shell', 'input', 'keyevent', String(Math.round(Number(payload.keyCode)))]);
140
+ return { success: true };
141
+ }
142
+ if (command.type === 'replay') {
143
+ const script = payload.script as AppiumRecordedScriptRecord | undefined;
144
+ if (!script) throw new Error('远程回放缺少脚本内容');
145
+ return await replayAppiumScript(script, command.deviceId);
146
+ }
147
+ throw new Error(`不支持的命令:${command.type}`);
148
+ }
149
+
150
+ async function heartbeat() {
151
+ const devices = await listDevices().catch((error) => {
152
+ console.warn(`扫描设备失败:${error instanceof Error ? error.message : String(error)}`);
153
+ return [];
154
+ });
155
+ await postJson(`${server}/api/remote-agents/heartbeat`, {
156
+ agentId,
157
+ agentName,
158
+ version: VERSION,
159
+ token,
160
+ devices,
161
+ });
162
+ console.log(`已上报 ${devices.length} 台设备到 ${server}`);
163
+ }
164
+
165
+ async function reportResult(command: RemoteCommand, ok: boolean, data?: unknown, error?: string) {
166
+ await postJson(`${server}/api/remote-agents/result`, {
167
+ agentId,
168
+ token,
169
+ commandId: command.id,
170
+ ok,
171
+ data,
172
+ error,
173
+ });
174
+ }
175
+
176
+ async function pollOnce() {
177
+ const url = new URL(`${server}/api/remote-agents/poll`);
178
+ url.searchParams.set('agentId', agentId);
179
+ if (token) url.searchParams.set('token', token);
180
+ const response = await fetch(url);
181
+ const payload = await response.json().catch(() => ({})) as { command?: RemoteCommand | null; message?: string };
182
+ if (!response.ok) throw new Error(payload.message || `HTTP ${response.status}`);
183
+ if (!payload.command) return;
184
+ try {
185
+ const result = await handleCommand(payload.command);
186
+ await reportResult(payload.command, true, result);
187
+ } catch (error) {
188
+ await reportResult(payload.command, false, undefined, error instanceof Error ? error.message : String(error));
189
+ }
190
+ }
191
+
192
+ async function main() {
193
+ console.log(`Remote Agent 启动:${agentId}`);
194
+ console.log(`中央服务:${server}`);
195
+ await heartbeat();
196
+ setInterval(() => void heartbeat().catch((error) => console.warn(`心跳失败:${error.message}`)), 3000);
197
+ for (;;) {
198
+ await pollOnce().catch((error) => console.warn(`轮询失败:${error.message}`));
199
+ await new Promise((resolve) => setTimeout(resolve, 300));
200
+ }
201
+ }
202
+
203
+ main().catch((error) => {
204
+ console.error(error);
205
+ process.exitCode = 1;
206
+ });