android-midscene-automation 0.1.21 → 0.1.23
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 +58 -128
- package/FAQ.md +91 -0
- package/README.md +71 -229
- package/USAGE.md +11 -100
- package/bin/android-midscene-automation.js +4 -0
- package/package.json +36 -20
- package/server/appium-recorder/appium-runner.ts +43 -6
- package/server/appium-recorder/repository.ts +1 -0
- package/server/appium-recorder/routes.ts +14 -1
- package/server/http-api.ts +8 -4
- package/server/model-tester.ts +22 -6
- package/src/App.vue +4 -1
- package/src/api.ts +3 -1
- package/src/appium-recorder/AppiumPage.vue +175 -87
- package/src/appium-recorder/api.ts +4 -0
- package/src/appium-recorder/components/FlowCanvas.vue +201 -0
- package/src/appium-recorder/components/FlowNodeCard.vue +264 -0
- package/src/appium-recorder/components/FlowRoundedEdge.vue +67 -0
- package/src/appium-recorder/components/NestedConditionBranches.vue +24 -29
- package/src/appium-recorder/components/RecordedSteps.vue +119 -1457
- package/src/appium-recorder/flow-graph.ts +709 -0
- package/src/appium-recorder/flow-labels.ts +81 -0
- package/src/appium-recorder/types.ts +1 -0
- package/src/components/device/DevicePreviewPanel.vue +4 -1
- package/src/main.ts +3 -0
- package/src/style.css +387 -38
- package/src/ui-refresh.css +619 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "android-midscene-automation",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.23",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/oooooooko/android-midscene-automation.git"
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"homepage": "https://github.com/oooooooko/android-midscene-automation#readme",
|
|
12
12
|
"type": "module",
|
|
13
13
|
"bin": {
|
|
14
|
-
"android-midscene-automation": "
|
|
14
|
+
"android-midscene-automation": "bin/android-midscene-automation.js"
|
|
15
15
|
},
|
|
16
16
|
"files": [
|
|
17
17
|
"bin",
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"vite.config.ts",
|
|
24
24
|
"README.md",
|
|
25
25
|
"USAGE.md",
|
|
26
|
+
"FAQ.md",
|
|
26
27
|
"CHANGELOG.md"
|
|
27
28
|
],
|
|
28
29
|
"scripts": {
|
|
@@ -35,25 +36,40 @@
|
|
|
35
36
|
"test:e2e": "playwright test"
|
|
36
37
|
},
|
|
37
38
|
"dependencies": {
|
|
38
|
-
"@
|
|
39
|
-
"@
|
|
40
|
-
"@midscene/
|
|
41
|
-
"@
|
|
42
|
-
"
|
|
43
|
-
"
|
|
44
|
-
"
|
|
45
|
-
"
|
|
46
|
-
"
|
|
47
|
-
"vue": "
|
|
48
|
-
"
|
|
49
|
-
"
|
|
50
|
-
"
|
|
39
|
+
"@dagrejs/dagre": "1.1.5",
|
|
40
|
+
"@element-plus/icons-vue": "2.3.2",
|
|
41
|
+
"@midscene/android": "https://registry.npmjs.org/@midscene/android/-/android-1.12.0.tgz",
|
|
42
|
+
"@midscene/android-playground": "1.12.0",
|
|
43
|
+
"@midscene/core": "https://registry.npmjs.org/@midscene/core/-/core-1.12.0.tgz",
|
|
44
|
+
"@midscene/playground": "https://registry.npmjs.org/@midscene/playground/-/playground-1.12.0.tgz",
|
|
45
|
+
"@midscene/shared": "https://registry.npmjs.org/@midscene/shared/-/shared-1.12.0.tgz",
|
|
46
|
+
"@midscene/web": "1.12.0",
|
|
47
|
+
"@vitejs/plugin-vue": "6.0.7",
|
|
48
|
+
"@vue-flow/core": "1.48.2",
|
|
49
|
+
"element-plus": "2.11.1",
|
|
50
|
+
"openai": "5.23.2",
|
|
51
|
+
"tsx": "4.23.0",
|
|
52
|
+
"typescript": "5.9.3",
|
|
53
|
+
"vite": "7.3.6",
|
|
54
|
+
"vue": "3.5.39",
|
|
55
|
+
"word-extractor": "1.0.4",
|
|
56
|
+
"xlsx": "0.18.5",
|
|
57
|
+
"yaml": "2.9.0"
|
|
58
|
+
},
|
|
59
|
+
"overrides": {
|
|
60
|
+
"@floating-ui/core": "1.7.5",
|
|
61
|
+
"@floating-ui/dom": "1.7.6",
|
|
62
|
+
"nanoid": "3.3.15",
|
|
63
|
+
"playwright": "1.61.1",
|
|
64
|
+
"playwright-core": "1.61.1",
|
|
65
|
+
"postcss": "8.5.16",
|
|
66
|
+
"rollup": "4.62.2"
|
|
51
67
|
},
|
|
52
68
|
"devDependencies": {
|
|
53
|
-
"@playwright/test": "
|
|
54
|
-
"@types/node": "
|
|
55
|
-
"@types/word-extractor": "
|
|
56
|
-
"@vue/tsconfig": "
|
|
57
|
-
"vue-tsc": "
|
|
69
|
+
"@playwright/test": "1.61.1",
|
|
70
|
+
"@types/node": "24.13.2",
|
|
71
|
+
"@types/word-extractor": "1.0.6",
|
|
72
|
+
"@vue/tsconfig": "0.7.0",
|
|
73
|
+
"vue-tsc": "3.3.6"
|
|
58
74
|
}
|
|
59
75
|
}
|
|
@@ -122,6 +122,27 @@ export function launchAppOnDevice(deviceId: string, packageName: string) {
|
|
|
122
122
|
});
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
export function clearAppDataOnDevice(deviceId: string, packageName: string) {
|
|
126
|
+
if (!packageName) return Promise.reject(new Error('清理 App 缓存缺少包名'));
|
|
127
|
+
return new Promise<void>((resolve, reject) => {
|
|
128
|
+
execFile(getAdbCommand(), [
|
|
129
|
+
'-s',
|
|
130
|
+
deviceId,
|
|
131
|
+
'shell',
|
|
132
|
+
'pm',
|
|
133
|
+
'clear',
|
|
134
|
+
packageName,
|
|
135
|
+
], { maxBuffer: 4 * 1024 * 1024 }, (error, stdout, stderr) => {
|
|
136
|
+
const output = `${stdout || ''}\n${stderr || ''}`.trim();
|
|
137
|
+
if (error || !/^success$/im.test(output)) {
|
|
138
|
+
reject(new Error(output || error?.message || `ADB 清理 ${packageName} 失败`));
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
resolve();
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
125
146
|
function adbSwipe(deviceId: string, step: Required<AppiumRecordedStepRecord>['swipe']) {
|
|
126
147
|
return new Promise<void>((resolve, reject) => {
|
|
127
148
|
execFile(getAdbCommand(), [
|
|
@@ -500,6 +521,12 @@ async function runStep(sessionId: string, deviceId: string, step: AppiumRecorded
|
|
|
500
521
|
return `ADB 已启动 ${packageName}`;
|
|
501
522
|
}
|
|
502
523
|
|
|
524
|
+
if (step.type === 'clearAppData') {
|
|
525
|
+
const packageName = step.value || '';
|
|
526
|
+
await clearAppDataOnDevice(deviceId, packageName);
|
|
527
|
+
return `ADB 已清除 ${packageName} 的应用数据与缓存`;
|
|
528
|
+
}
|
|
529
|
+
|
|
503
530
|
if (step.type === 'screenshot') {
|
|
504
531
|
await saveScreenshot(sessionId);
|
|
505
532
|
return;
|
|
@@ -710,10 +737,20 @@ async function appendStepDiagnostics(lines: string[], deviceId: string, step: Ap
|
|
|
710
737
|
}
|
|
711
738
|
|
|
712
739
|
type ReplayStepOptions = {
|
|
713
|
-
|
|
740
|
+
skipAppInitialization?: boolean;
|
|
714
741
|
scriptName?: string;
|
|
715
742
|
};
|
|
716
743
|
|
|
744
|
+
function shouldSkipLinkedScriptInitStep(options: ReplayStepOptions, step: AppiumRecordedStepRecord) {
|
|
745
|
+
return Boolean(options.skipAppInitialization && (step.type === 'launchApp' || step.type === 'clearAppData'));
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
function linkedScriptInitSkipReason(step: AppiumRecordedStepRecord) {
|
|
749
|
+
return step.type === 'clearAppData'
|
|
750
|
+
? '连接脚本不重复清理 App'
|
|
751
|
+
: '连接脚本不重复启动 App';
|
|
752
|
+
}
|
|
753
|
+
|
|
717
754
|
async function replayLinkedScript(
|
|
718
755
|
sessionId: string,
|
|
719
756
|
deviceId: string,
|
|
@@ -754,7 +791,7 @@ async function replayLinkedScript(
|
|
|
754
791
|
lines.push(`连接脚本开始:${linkedScript.name}`);
|
|
755
792
|
const nextStack = [...stack, linkedScript.id];
|
|
756
793
|
await replayScriptSteps(sessionId, deviceId, linkedScript.steps, lines, nextStack, {
|
|
757
|
-
|
|
794
|
+
skipAppInitialization: true,
|
|
758
795
|
scriptName: linkedScript.name,
|
|
759
796
|
});
|
|
760
797
|
lines.push(`连接脚本完成:${linkedScript.name}`);
|
|
@@ -770,8 +807,8 @@ async function replayLinearSteps(
|
|
|
770
807
|
) {
|
|
771
808
|
for (const [index, step] of steps.entries()) {
|
|
772
809
|
throwIfReplayStopped();
|
|
773
|
-
if (options
|
|
774
|
-
lines.push(`[节点 ${index + 1}] 跳过:${step.label}
|
|
810
|
+
if (shouldSkipLinkedScriptInitStep(options, step)) {
|
|
811
|
+
lines.push(`[节点 ${index + 1}] 跳过:${step.label}(${linkedScriptInitSkipReason(step)})`);
|
|
775
812
|
continue;
|
|
776
813
|
}
|
|
777
814
|
lines.push(`[节点 ${index + 1}] 开始:${step.label}`);
|
|
@@ -841,8 +878,8 @@ async function replayFlowSteps(
|
|
|
841
878
|
if (guard > maxVisits) throw new Error('流程图可能存在循环,已终止回放');
|
|
842
879
|
|
|
843
880
|
const step = steps[index];
|
|
844
|
-
if (options
|
|
845
|
-
lines.push(`[节点 ${index + 1}] 跳过:${step.label}
|
|
881
|
+
if (shouldSkipLinkedScriptInitStep(options, step)) {
|
|
882
|
+
lines.push(`[节点 ${index + 1}] 跳过:${step.label}(${linkedScriptInitSkipReason(step)})`);
|
|
846
883
|
index = nextIndexAfterStep(index, step);
|
|
847
884
|
continue;
|
|
848
885
|
}
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
saveAppiumRecordedScript,
|
|
12
12
|
type AppiumRecordedStepRecord,
|
|
13
13
|
} from './repository';
|
|
14
|
-
import { launchAppOnDevice, replayAppiumScript } from './appium-runner';
|
|
14
|
+
import { clearAppDataOnDevice, launchAppOnDevice, replayAppiumScript } from './appium-runner';
|
|
15
15
|
import { isRemoteDeviceId, sendRemoteCommand } from '../remote-agents/registry';
|
|
16
16
|
import { getAdbCommand } from '../android-sdk';
|
|
17
17
|
|
|
@@ -205,6 +205,19 @@ export async function handleAppiumRecorderRequest(
|
|
|
205
205
|
return true;
|
|
206
206
|
}
|
|
207
207
|
|
|
208
|
+
if (pathname === '/api/appium-recorder/clear-app-data' && req.method === 'POST') {
|
|
209
|
+
const parsed = await readBody<{ deviceId?: string; packageName?: string }>(req);
|
|
210
|
+
const deviceId = parsed.deviceId?.trim() || selectedDeviceId;
|
|
211
|
+
const packageName = parsed.packageName?.trim() || '';
|
|
212
|
+
if (!deviceId) throw new Error('未检测到可用设备');
|
|
213
|
+
if (!packageName) throw new Error('请选择预设 App');
|
|
214
|
+
assertDeviceAllowed(deviceId);
|
|
215
|
+
if (isRemoteDeviceId(deviceId)) throw new Error('远程设备暂不支持清理 App 缓存');
|
|
216
|
+
await clearAppDataOnDevice(deviceId, packageName);
|
|
217
|
+
sendJson(res, { success: true });
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
|
|
208
221
|
if (pathname === '/api/appium-recorder/scripts' && req.method === 'GET') {
|
|
209
222
|
sendJson(res, { scripts: listAppiumRecordedScripts() });
|
|
210
223
|
return true;
|
package/server/http-api.ts
CHANGED
|
@@ -95,9 +95,13 @@ function writeNdjson(res: ServerResponse, event: RunGeneratedScriptEvent | { typ
|
|
|
95
95
|
res.write(`${JSON.stringify(event)}\n`);
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
}
|
|
98
|
+
const START_ANDROID_PLAYGROUND_WITHOUT_BROWSER = [
|
|
99
|
+
"import { androidPlaygroundPlatform, ScrcpyServer } from '@midscene/android-playground';",
|
|
100
|
+
"import { launchPreparedPlaygroundPlatform } from '@midscene/playground';",
|
|
101
|
+
'const scrcpyServer = new ScrcpyServer();',
|
|
102
|
+
'const prepared = await androidPlaygroundPlatform.prepare({ scrcpyServer });',
|
|
103
|
+
'await launchPreparedPlaygroundPlatform(prepared);',
|
|
104
|
+
].join('\n');
|
|
101
105
|
|
|
102
106
|
type AndroidDevice = {
|
|
103
107
|
id: string;
|
|
@@ -513,7 +517,7 @@ export function createApiMiddleware() {
|
|
|
513
517
|
};
|
|
514
518
|
}
|
|
515
519
|
|
|
516
|
-
playgroundProcess = spawn(
|
|
520
|
+
playgroundProcess = spawn(process.execPath, ['--input-type=module', '--eval', START_ANDROID_PLAYGROUND_WITHOUT_BROWSER], {
|
|
517
521
|
cwd: appPath(),
|
|
518
522
|
env: {
|
|
519
523
|
...process.env,
|
package/server/model-tester.ts
CHANGED
|
@@ -30,6 +30,18 @@ type ModelTestResult = {
|
|
|
30
30
|
usage?: ModelUsage;
|
|
31
31
|
};
|
|
32
32
|
|
|
33
|
+
function normalizeBaseUrl(value: string) {
|
|
34
|
+
return value.trim().replace(/\/+$/, '');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function normalizeModelName(value: string) {
|
|
38
|
+
return value.trim().replace(/[‐‑‒–—―]/g, '-');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function visibleContent(value: string) {
|
|
42
|
+
return value.replace(/<think>[\s\S]*?<\/think>/g, '').trim() || value.trim();
|
|
43
|
+
}
|
|
44
|
+
|
|
33
45
|
function execCodex(args: string[]) {
|
|
34
46
|
return new Promise<string>((resolve, reject) => {
|
|
35
47
|
execFile('codex', args, { timeout: 10_000 }, (error, stdout, stderr) => {
|
|
@@ -63,23 +75,27 @@ async function testCodexConnection(input: ModelInput): Promise<ModelTestResult>
|
|
|
63
75
|
}
|
|
64
76
|
|
|
65
77
|
export async function testModelConnection(input: ModelInput): Promise<ModelTestResult> {
|
|
66
|
-
|
|
78
|
+
const baseUrl = normalizeBaseUrl(input.baseUrl);
|
|
79
|
+
const modelName = normalizeModelName(input.name);
|
|
80
|
+
|
|
81
|
+
if (input.provider === 'codex' || baseUrl === 'codex://app-server') {
|
|
67
82
|
return testCodexConnection(input);
|
|
68
83
|
}
|
|
69
84
|
|
|
70
|
-
if (!
|
|
85
|
+
if (!baseUrl || !input.apiKey.trim() || !modelName) {
|
|
71
86
|
throw new Error('模型配置不完整');
|
|
72
87
|
}
|
|
73
88
|
|
|
74
89
|
const startedAt = Date.now();
|
|
75
90
|
const client = new OpenAI({
|
|
76
|
-
apiKey: input.apiKey,
|
|
77
|
-
baseURL:
|
|
91
|
+
apiKey: input.apiKey.trim(),
|
|
92
|
+
baseURL: baseUrl,
|
|
78
93
|
});
|
|
79
94
|
|
|
80
95
|
const result = await client.chat.completions.create({
|
|
81
|
-
model:
|
|
96
|
+
model: modelName,
|
|
82
97
|
temperature: 0,
|
|
98
|
+
max_completion_tokens: 128,
|
|
83
99
|
messages: [
|
|
84
100
|
{
|
|
85
101
|
role: 'user',
|
|
@@ -88,7 +104,7 @@ export async function testModelConnection(input: ModelInput): Promise<ModelTestR
|
|
|
88
104
|
],
|
|
89
105
|
});
|
|
90
106
|
|
|
91
|
-
const content = result.choices[0]?.message?.content
|
|
107
|
+
const content = visibleContent(result.choices[0]?.message?.content || '');
|
|
92
108
|
const usage: ModelUsage = {
|
|
93
109
|
promptTokens: result.usage?.prompt_tokens,
|
|
94
110
|
completionTokens: result.usage?.completion_tokens,
|
package/src/App.vue
CHANGED
|
@@ -1391,7 +1391,10 @@ onUnmounted(() => {
|
|
|
1391
1391
|
|
|
1392
1392
|
<main
|
|
1393
1393
|
class="page-body"
|
|
1394
|
-
:class="{
|
|
1394
|
+
:class="{
|
|
1395
|
+
'page-body--fixed': activeMenu === 'generator' && activeGeneratorMode === 'ai',
|
|
1396
|
+
'page-body--appium': activeMenu === 'appium',
|
|
1397
|
+
}"
|
|
1395
1398
|
>
|
|
1396
1399
|
<el-alert v-if="activeMenu !== 'appium' && errorMessage" type="error" :closable="false" show-icon class="page-alert">
|
|
1397
1400
|
<template #title>{{ errorMessage }}</template>
|
package/src/api.ts
CHANGED
|
@@ -32,6 +32,8 @@ async function postJson<T>(url: string, body?: unknown) {
|
|
|
32
32
|
method: 'POST',
|
|
33
33
|
headers: { 'Content-Type': 'application/json' },
|
|
34
34
|
body: JSON.stringify(body || {}),
|
|
35
|
+
}).catch((error) => {
|
|
36
|
+
throw new Error(`无法连接本地后端 ${url}:${error instanceof Error ? error.message : '网络请求失败'}`);
|
|
35
37
|
});
|
|
36
38
|
return readJson<T>(response);
|
|
37
39
|
}
|
|
@@ -287,4 +289,4 @@ export async function runScript(
|
|
|
287
289
|
if (buffer.trim()) {
|
|
288
290
|
onEvent(JSON.parse(buffer) as RunScriptStreamEvent);
|
|
289
291
|
}
|
|
290
|
-
}
|
|
292
|
+
}
|