android-midscene-automation 0.1.41 → 0.1.42
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 +11 -0
- package/README.md +13 -0
- package/USAGE.md +2 -0
- package/package.json +2 -1
- package/remote-agent/index.ts +1 -0
- package/server/appium-auto-save.test.ts +60 -0
- package/server/appium-config.test.ts +118 -0
- package/server/appium-recorder/ai-deduplication.test.ts +80 -0
- package/server/appium-recorder/ai-deduplication.ts +74 -0
- package/server/appium-recorder/ai-observation.test.ts +125 -0
- package/server/appium-recorder/ai-observation.ts +125 -0
- package/server/appium-recorder/ai-recognition.ts +118 -22
- package/server/appium-recorder/appium-runner.ts +59 -14
- package/server/appium-recorder/history-capture.ts +1 -0
- package/server/appium-recorder/image-check.ts +1 -1
- package/server/appium-recorder/managed-appium.ts +15 -0
- package/server/appium-recorder/managed-cleanup.test.ts +82 -0
- package/server/appium-recorder/replay-log.ts +13 -0
- package/server/appium-recorder/report.ts +14 -12
- package/server/appium-recorder/repository.ts +5 -0
- package/server/appium-recorder/routes.ts +26 -2
- package/server/appium-recorder/screenshot.ts +1 -0
- package/server/config.ts +19 -4
- package/server/http-api.ts +26 -4
- package/server/model-tester.ts +2 -1
- package/src/App.vue +41 -29
- package/src/api.ts +10 -3
- package/src/appium-recorder/AppiumPage.vue +41 -6
- package/src/appium-recorder/RunHistoryDialog.vue +1 -1
- package/src/appium-recorder/action-descriptions.ts +1 -1
- package/src/appium-recorder/ai-deduplication.ts +34 -0
- package/src/appium-recorder/ai-prompt-presets.ts +34 -0
- package/src/appium-recorder/ai-recognition.ts +27 -2
- package/src/appium-recorder/api.ts +14 -0
- package/src/appium-recorder/components/AiRecognitionPromptInput.vue +41 -0
- package/src/appium-recorder/components/AiRecognitionSettings.vue +143 -0
- package/src/appium-recorder/components/AiRecognitionTestDialog.vue +86 -0
- package/src/appium-recorder/components/FlowCanvas.vue +3 -2
- package/src/appium-recorder/components/FlowNodeCard.vue +2 -2
- package/src/appium-recorder/components/FlowStepEditor.vue +8 -2
- package/src/appium-recorder/components/NestedConditionBranches.vue +7 -1
- package/src/appium-recorder/components/RecordedSteps.vue +7 -1
- package/src/appium-recorder/flow-appearance.ts +12 -1
- package/src/appium-recorder/flow-graph.ts +2 -2
- package/src/appium-recorder/flow-labels.ts +6 -4
- package/src/appium-recorder/types.ts +4 -0
- package/src/components/config/AiDeduplicationSettings.vue +59 -0
- package/src/components/config/AiPromptPresetsSettings.vue +66 -0
- package/src/components/config/AppiumConfigPanel.vue +48 -3
- package/src/components/config/BaseConfigPanel.vue +20 -8
- package/src/components/config/FlowAppearanceSettings.vue +51 -31
- package/src/components/config/MidsceneConfigPanel.vue +84 -86
- package/src/config/midscene-model-presets.ts +37 -9
- package/src/config/use-appium-auto-save.ts +54 -0
- package/src/pages/ConfigPage.vue +4 -4
- package/src/style.css +5 -10
- package/src/types.ts +3 -1
- package/src/ui-refresh.css +17 -4
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
import childProcess from 'node:child_process';
|
|
4
|
+
import { EventEmitter } from 'node:events';
|
|
5
|
+
import { PassThrough } from 'node:stream';
|
|
6
|
+
import { syncBuiltinESMExports } from 'node:module';
|
|
7
|
+
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
|
|
8
|
+
import { tmpdir } from 'node:os';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
|
|
11
|
+
test('owned device instrumentation is cleaned after success, abort and failed session creation; external servers are untouched', async () => {
|
|
12
|
+
const root = await mkdtemp(join(tmpdir(), 'managed-cleanup-'));
|
|
13
|
+
const env = { ...process.env };
|
|
14
|
+
const original = { execFile: childProcess.execFile, spawn: childProcess.spawn, fetch: globalThis.fetch };
|
|
15
|
+
const calls: string[][] = [];
|
|
16
|
+
let mode = 'success', stopped = false;
|
|
17
|
+
let controller = new AbortController();
|
|
18
|
+
try {
|
|
19
|
+
const sdk = join(root, 'sdk');
|
|
20
|
+
await mkdir(join(sdk, 'platform-tools'), { recursive: true });
|
|
21
|
+
await writeFile(join(sdk, 'platform-tools', 'adb'), '');
|
|
22
|
+
Object.assign(process.env, { ANDROID_MIDSCENE_DATA_ROOT: root, ANDROID_HOME: sdk, ANDROID_SDK_ROOT: sdk });
|
|
23
|
+
delete process.env.APPIUM_SERVER_URL;
|
|
24
|
+
childProcess.spawn = (() => {
|
|
25
|
+
stopped = false;
|
|
26
|
+
const child = Object.assign(new EventEmitter(), {
|
|
27
|
+
pid: 12345, exitCode: null as number | null, stdout: new PassThrough(), stderr: new PassThrough(),
|
|
28
|
+
kill: () => { stopped = true; child.exitCode = 0; child.emit('exit', 0); return true; },
|
|
29
|
+
});
|
|
30
|
+
return child;
|
|
31
|
+
}) as unknown as typeof childProcess.spawn;
|
|
32
|
+
childProcess.execFile = ((_command, args, options, callback) => {
|
|
33
|
+
if (args.includes('force-stop')) {
|
|
34
|
+
assert.equal(stopped, true, 'stop owned host server before device cleanup');
|
|
35
|
+
assert.equal(options.signal, undefined, 'cleanup must not inherit an aborted signal');
|
|
36
|
+
assert.equal(options.timeout, 3000);
|
|
37
|
+
calls.push(args);
|
|
38
|
+
}
|
|
39
|
+
queueMicrotask(() => callback(mode === 'cleanup-failure' && args.includes('force-stop') ? new Error('offline') : null, '', ''));
|
|
40
|
+
return {};
|
|
41
|
+
}) as typeof childProcess.execFile;
|
|
42
|
+
syncBuiltinESMExports();
|
|
43
|
+
globalThis.fetch = async (input, init) => {
|
|
44
|
+
const path = new URL(String(input)).pathname;
|
|
45
|
+
if (path === '/session' && init?.method === 'POST') {
|
|
46
|
+
if (mode === 'creation-failure') return Response.json({ value: { error: 'session not created', message: 'creation failed' } }, { status: 500 });
|
|
47
|
+
return Response.json({ value: { sessionId: 'test-session' } });
|
|
48
|
+
}
|
|
49
|
+
return Response.json({ value: {} });
|
|
50
|
+
};
|
|
51
|
+
const { replayAppiumScript } = await import('./appium-runner');
|
|
52
|
+
const script = {
|
|
53
|
+
id: 'cleanup', name: 'cleanup', deviceId: 'test-device', appPackage: '', appActivity: '', createdAt: '', updatedAt: '',
|
|
54
|
+
steps: [{ id: 'log', type: 'log' as const, label: 'log', value: 'ok' }],
|
|
55
|
+
};
|
|
56
|
+
for (mode of ['success', 'abort', 'creation-failure', 'cleanup-failure', 'external']) {
|
|
57
|
+
calls.length = 0;
|
|
58
|
+
controller = new AbortController();
|
|
59
|
+
if (mode === 'external') process.env.APPIUM_SERVER_URL = 'http://external.invalid';
|
|
60
|
+
const result = await replayAppiumScript(script, 'test-device', line => {
|
|
61
|
+
if (mode === 'abort' && line.includes('Appium session 已创建')) controller.abort();
|
|
62
|
+
}, controller.signal);
|
|
63
|
+
assert.equal(calls.length, mode === 'external' ? 0 : 2);
|
|
64
|
+
if (mode !== 'external') {
|
|
65
|
+
assert.deepEqual(calls.map(args => args.slice(0, 5)), Array(2).fill(['-s', 'test-device', 'shell', 'am', 'force-stop']));
|
|
66
|
+
assert.deepEqual(calls.map(args => args.at(-1)).sort(), ['io.appium.uiautomator2.server', 'io.appium.uiautomator2.server.test']);
|
|
67
|
+
}
|
|
68
|
+
if (mode === 'abort') assert.equal(result.stopped, true);
|
|
69
|
+
if (mode === 'creation-failure') assert.equal(result.success, false);
|
|
70
|
+
if (mode === 'cleanup-failure') assert.match(result.output, /UiAutomator2 清理失败/);
|
|
71
|
+
}
|
|
72
|
+
} finally {
|
|
73
|
+
childProcess.execFile = original.execFile;
|
|
74
|
+
childProcess.spawn = original.spawn;
|
|
75
|
+
globalThis.fetch = original.fetch;
|
|
76
|
+
syncBuiltinESMExports();
|
|
77
|
+
for (const key of ['ANDROID_MIDSCENE_DATA_ROOT', 'ANDROID_HOME', 'ANDROID_SDK_ROOT', 'APPIUM_SERVER_URL']) {
|
|
78
|
+
if (env[key] === undefined) delete process.env[key]; else process.env[key] = env[key];
|
|
79
|
+
}
|
|
80
|
+
await rm(root, { recursive: true, force: true });
|
|
81
|
+
}
|
|
82
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
const TIME_PREFIX = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}:\d{3} - /;
|
|
2
|
+
|
|
3
|
+
export function stripReplayLogTime(line: string) {
|
|
4
|
+
return line.replace(TIME_PREFIX, '');
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function timestampReplayLog(message: string, now = new Date()) {
|
|
8
|
+
const date = [now.getFullYear(), now.getMonth() + 1, now.getDate()].map(value => String(value).padStart(2, '0')).join('-');
|
|
9
|
+
const time = [now.getHours(), now.getMinutes(), now.getSeconds()].map(value => String(value).padStart(2, '0')).join(':');
|
|
10
|
+
const prefix = `${date} ${time}:${String(now.getMilliseconds()).padStart(3, '0')} - `;
|
|
11
|
+
// Appium 原始日志保留原时间,多行操作信息逐行补齐时间。
|
|
12
|
+
return message.split(/\r?\n/).map(line => !line || TIME_PREFIX.test(line) ? line : prefix + line).join('\n');
|
|
13
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { mkdir, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { stripReplayLogTime } from './replay-log';
|
|
2
3
|
import { join, relative } from 'node:path';
|
|
3
4
|
import type { ReplayVideo } from './replay-video';
|
|
4
5
|
import { PNG } from 'pngjs';
|
|
@@ -20,7 +21,7 @@ export type AppiumReplayFrame = {
|
|
|
20
21
|
logContent?: string;
|
|
21
22
|
note: string;
|
|
22
23
|
selector: string;
|
|
23
|
-
phase: 'before' | 'after' | 'error' | 'stopped';
|
|
24
|
+
phase: 'before' | 'after' | 'error' | 'stopped' | 'observation';
|
|
24
25
|
status: string;
|
|
25
26
|
capturedAt: string;
|
|
26
27
|
imageBase64: string;
|
|
@@ -152,13 +153,13 @@ function selectorText(selector?: AppiumRecordedStepRecord['selector']) {
|
|
|
152
153
|
|
|
153
154
|
function stepExecutionLines(outputLines: string[], index: number) {
|
|
154
155
|
const prefixes = [`[节点 ${index + 1}]`, `[步骤 ${index + 1}]`];
|
|
155
|
-
return outputLines.filter((line) => prefixes.some((prefix) => line.startsWith(prefix)));
|
|
156
|
+
return outputLines.filter((line) => prefixes.some((prefix) => stripReplayLogTime(line).startsWith(prefix)));
|
|
156
157
|
}
|
|
157
158
|
|
|
158
159
|
function executionStatus(lines: string[]) {
|
|
159
160
|
if (!lines.length) return '未执行';
|
|
160
161
|
// 只识别执行器的状态前缀,用户日志中的“失败”等文字不是执行结果。
|
|
161
|
-
const messages = lines.map((line) => line.replace(/^\[(?:节点|步骤) \d+\] /, ''));
|
|
162
|
+
const messages = lines.map((line) => stripReplayLogTime(line).replace(/^\[(?:节点|步骤) \d+\] /, ''));
|
|
162
163
|
if (messages.some((line) => line.startsWith('失败:') || line.startsWith('失败分支:'))) return '失败';
|
|
163
164
|
if (messages.some((line) => line.startsWith('跳过:'))) return '跳过';
|
|
164
165
|
if (messages.some((line) => line.startsWith('判断:'))) return '判断完成';
|
|
@@ -467,7 +468,7 @@ function createReplayHtml(input: {
|
|
|
467
468
|
frames.forEach((frame, index) => {
|
|
468
469
|
const key = JSON.stringify([frame.scriptName, frame.nodeId, frame.nodeNumber]);
|
|
469
470
|
const pending = pendingSteps.get(key) || [];
|
|
470
|
-
let stepIndex = frame.phase === 'before' ? undefined : pending.pop();
|
|
471
|
+
let stepIndex = frame.phase === 'before' ? undefined : frame.phase === 'observation' ? pending.at(-1) : pending.pop();
|
|
471
472
|
if (stepIndex === undefined) {
|
|
472
473
|
stepIndex = steps.length;
|
|
473
474
|
steps.push({ firstIndex: index, lastIndex: index });
|
|
@@ -496,7 +497,7 @@ function createReplayHtml(input: {
|
|
|
496
497
|
let currentMs = 0;
|
|
497
498
|
let animationFrame = 0;
|
|
498
499
|
let playbackStartedAt = 0;
|
|
499
|
-
const phaseLabels = { before: '执行前', after: '执行后', error: '失败', stopped: '已终止' };
|
|
500
|
+
const phaseLabels = { before: '执行前', after: '执行后', observation: '观察采样', error: '失败', stopped: '已终止' };
|
|
500
501
|
const visualCheckStatusLabel = (status) => status === 'passed' ? '有变化' : '无明显变化';
|
|
501
502
|
const formatTime = (milliseconds) => {
|
|
502
503
|
const seconds = Math.max(0, Math.floor(milliseconds / 1000));
|
|
@@ -802,6 +803,7 @@ function createReplayHtml(input: {
|
|
|
802
803
|
}
|
|
803
804
|
|
|
804
805
|
export async function createAppiumReplayReport(input: {
|
|
806
|
+
screenshotReport?: boolean;
|
|
805
807
|
video?: ReplayVideo;
|
|
806
808
|
script: AppiumRecordedScriptRecord;
|
|
807
809
|
deviceId: string;
|
|
@@ -820,14 +822,14 @@ export async function createAppiumReplayReport(input: {
|
|
|
820
822
|
const baseName = `${fileDateTime(input.completedAt)}-${safeFileName(input.script.name)}`;
|
|
821
823
|
const fileName = `${baseName}.md`;
|
|
822
824
|
const logFileName = `${baseName}.log`;
|
|
823
|
-
const htmlFileName = `${baseName}.html
|
|
825
|
+
const htmlFileName = (input.screenshotReport ?? loadConfig().appium.screenshotReport) === true ? `${baseName}.html` : '';
|
|
824
826
|
const filePath = join(outputDir, fileName);
|
|
825
827
|
const logPath = join(outputDir, logFileName);
|
|
826
|
-
const htmlReportPath = join(outputDir, htmlFileName);
|
|
828
|
+
const htmlReportPath = htmlFileName ? join(outputDir, htmlFileName) : '';
|
|
827
829
|
const persistedOutput = [
|
|
828
830
|
input.output,
|
|
829
831
|
`回放报告:${filePath}`,
|
|
830
|
-
`截图回放:${htmlReportPath}
|
|
832
|
+
htmlReportPath ? `截图回放:${htmlReportPath}` : '',
|
|
831
833
|
`回放日志:${logPath}`,
|
|
832
834
|
].filter(Boolean).join('\n');
|
|
833
835
|
const outputLines = persistedOutput.split(/\r?\n/);
|
|
@@ -836,7 +838,7 @@ export async function createAppiumReplayReport(input: {
|
|
|
836
838
|
const visualChecks = input.visualChecks || [];
|
|
837
839
|
const visualCheckByStepId = new Map(visualChecks.map((check) => [check.nodeId, check]));
|
|
838
840
|
const visualFailureCount = visualChecks.filter((check) => check.status === 'failed').length;
|
|
839
|
-
const html = createReplayHtml({
|
|
841
|
+
const html = htmlReportPath ? createReplayHtml({
|
|
840
842
|
video: input.video ? {
|
|
841
843
|
startedAt: input.video.startedAt,
|
|
842
844
|
url: relative(outputDir, input.video.filePath).split(/[/\\\\]/).map(encodeURIComponent).join('/'),
|
|
@@ -855,7 +857,7 @@ export async function createAppiumReplayReport(input: {
|
|
|
855
857
|
visualChecks,
|
|
856
858
|
imageChecks: input.imageChecks,
|
|
857
859
|
output: persistedOutput,
|
|
858
|
-
});
|
|
860
|
+
}) : '';
|
|
859
861
|
const markdown = [
|
|
860
862
|
'# Appium 回放报告',
|
|
861
863
|
'',
|
|
@@ -873,7 +875,7 @@ export async function createAppiumReplayReport(input: {
|
|
|
873
875
|
`| 节点数量 | ${input.script.steps.length} |`,
|
|
874
876
|
`| 截图帧数 | ${(input.frames || []).length} |`,
|
|
875
877
|
`| 视觉检测未达预期 | ${visualFailureCount} |`,
|
|
876
|
-
`| 截图回放 | ${markdownValue(htmlReportPath)} |`,
|
|
878
|
+
`| 截图回放 | ${htmlReportPath ? markdownValue(htmlReportPath) : '未开启'} |`,
|
|
877
879
|
'',
|
|
878
880
|
'## 节点明细',
|
|
879
881
|
'',
|
|
@@ -888,7 +890,7 @@ export async function createAppiumReplayReport(input: {
|
|
|
888
890
|
await Promise.all([
|
|
889
891
|
writeFile(filePath, markdown, 'utf8'),
|
|
890
892
|
writeFile(logPath, `${persistedOutput}\n`, 'utf8'),
|
|
891
|
-
writeFile(htmlReportPath, html, 'utf8'),
|
|
893
|
+
...(htmlReportPath ? [writeFile(htmlReportPath, html, 'utf8')] : []),
|
|
892
894
|
]);
|
|
893
895
|
const record = saveAppiumReplayReport({
|
|
894
896
|
scriptId: input.script.id,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { validateAiObservation, type AiObservationConfig } from '../../src/appium-recorder/ai-recognition';
|
|
1
2
|
import {
|
|
2
3
|
createId,
|
|
3
4
|
querySql,
|
|
@@ -99,6 +100,9 @@ export type AppiumRecordedStepRecord = {
|
|
|
99
100
|
returns?: ScriptReturn[];
|
|
100
101
|
keyCode?: number;
|
|
101
102
|
timeoutMs?: number;
|
|
103
|
+
aiTimeoutEnabled?: boolean;
|
|
104
|
+
aiBranchEnabled?: boolean;
|
|
105
|
+
aiObservation?: AiObservationConfig;
|
|
102
106
|
timeoutBranch?: 'stop' | 'yes' | 'no';
|
|
103
107
|
longPressMode?: 'element' | 'coordinates';
|
|
104
108
|
breakLoopTargetId?: string;
|
|
@@ -376,6 +380,7 @@ export function saveAppiumRecordedScript(input: {
|
|
|
376
380
|
if (input.variables !== undefined) validateVariables(input.variables);
|
|
377
381
|
for (const step of steps) {
|
|
378
382
|
// 读取旧脚本必须容错,让用户能打开并修正无效配置;仅在写入时校验。
|
|
383
|
+
if (step.type === 'aiRecognition') validateAiObservation(step.aiObservation);
|
|
379
384
|
if (step.type === 'imageCheck') validateImageCheck(step.imageCheck);
|
|
380
385
|
if (step.type === 'extractVariable') validateExtraction(step.extractVariable);
|
|
381
386
|
if (step.type === 'runScript') { validateVariables(step.parameters); validateReturns(step.returns); }
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
import type { AiObservationConfig } from '../../src/appium-recorder/ai-recognition';
|
|
1
2
|
import { execFile } from 'node:child_process';
|
|
2
3
|
import { sendReplayVideo } from './video-response';
|
|
4
|
+
import { loadConfig } from '../config';
|
|
5
|
+
import { recognizeDeviceScreen, validateAiBranchQuestion } from './ai-recognition';
|
|
3
6
|
import { getPresetVariables, savePresetVariables } from './variable-store';
|
|
4
7
|
import type { TestVariable } from '../../src/appium-recorder/variables';
|
|
5
8
|
import { deleteRunHistory, getRunHistory, listRunHistory, saveRunHistory } from './run-history';
|
|
@@ -199,8 +202,29 @@ export async function handleAppiumRecorderRequest(
|
|
|
199
202
|
return true;
|
|
200
203
|
}
|
|
201
204
|
|
|
205
|
+
if (pathname === '/api/appium-recorder/ai-recognition/validate-question' && req.method === 'POST') {
|
|
206
|
+
const parsed = await readBody<{ prompt?: unknown }>(req);
|
|
207
|
+
const controller = new AbortController();
|
|
208
|
+
const abort = () => controller.abort();
|
|
209
|
+
res.once('close', abort);
|
|
210
|
+
try { sendJson(res, await validateAiBranchQuestion(parsed.prompt, controller.signal)); }
|
|
211
|
+
finally { res.off('close', abort); }
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
|
|
202
215
|
if (pathname === '/api/appium-recorder/ai-recognition/test' && req.method === 'POST') {
|
|
203
|
-
|
|
216
|
+
const parsed = await readBody<{ deviceId?: string; prompt?: unknown; timeoutMs?: number; aiTimeoutEnabled?: boolean; aiBranchEnabled?: boolean; aiObservation?: AiObservationConfig }>(req);
|
|
217
|
+
const deviceId = parsed.deviceId?.trim() || selectedDeviceId;
|
|
218
|
+
if (!deviceId) throw new Error('请选择设备');
|
|
219
|
+
assertDeviceAllowed(deviceId);
|
|
220
|
+
if (isRemoteDeviceId(deviceId)) throw new Error('远程设备暂不支持 AI 识别测试');
|
|
221
|
+
if (replayingDevices.has(deviceId)) { sendJson(res, { message: '设备正在回放,请稍后测试' }, 409); return true; }
|
|
222
|
+
const controller = new AbortController();
|
|
223
|
+
const abort = () => controller.abort();
|
|
224
|
+
res.once('close', abort);
|
|
225
|
+
try {
|
|
226
|
+
sendJson(res, await recognizeDeviceScreen({ deviceId, prompt: parsed.prompt, timeoutMs: parsed.timeoutMs, aiTimeoutEnabled: parsed.aiTimeoutEnabled === true, aiBranchEnabled: parsed.aiBranchEnabled, aiObservation: parsed.aiObservation, signal: controller.signal }));
|
|
227
|
+
} finally { res.off('close', abort); }
|
|
204
228
|
return true;
|
|
205
229
|
}
|
|
206
230
|
|
|
@@ -375,7 +399,7 @@ export async function handleAppiumRecorderRequest(
|
|
|
375
399
|
res.flushHeaders();
|
|
376
400
|
}
|
|
377
401
|
if (isRemoteDeviceId(deviceId)) {
|
|
378
|
-
const result = await sendRemoteCommand(deviceId, 'replay', { script, parameters: parsed.parameters, globalVariables: getPresetVariables(), linkedScripts: linkedScriptSnapshot(script) }) as Awaited<ReturnType<typeof replayAppiumScript>>;
|
|
402
|
+
const result = await sendRemoteCommand(deviceId, 'replay', { script, screenshotReport: loadConfig().appium.screenshotReport === true, parameters: parsed.parameters, globalVariables: getPresetVariables(), linkedScripts: linkedScriptSnapshot(script) }) as Awaited<ReturnType<typeof replayAppiumScript>>;
|
|
379
403
|
if (result.history) {
|
|
380
404
|
try { saveRunHistory({ ...result.history, scriptId: script.id, deviceId }); }
|
|
381
405
|
catch { result.output += '\n历史记录保存失败'; }
|
|
@@ -9,6 +9,7 @@ export function adbScreenshotBase64(deviceId: string, signal?: AbortSignal) {
|
|
|
9
9
|
['-s', deviceId, 'exec-out', 'screencap', '-p'],
|
|
10
10
|
{ encoding: 'buffer', maxBuffer: 20 * 1024 * 1024, timeout: 8000, signal },
|
|
11
11
|
(error, stdout, stderr) => {
|
|
12
|
+
if (signal?.aborted) { reject(signal.reason); return; }
|
|
12
13
|
if (error) {
|
|
13
14
|
reject(new Error(stderr?.toString('utf8').trim() || error.message || 'ADB 截图失败'));
|
|
14
15
|
return;
|
package/server/config.ts
CHANGED
|
@@ -1,21 +1,25 @@
|
|
|
1
|
+
import type { AiPromptPreset } from '../src/appium-recorder/ai-prompt-presets';
|
|
1
2
|
import fs from 'node:fs';
|
|
2
|
-
import { isHexColor, normalizeFlowBackground } from '../src/appium-recorder/flow-appearance';
|
|
3
|
+
import { isHexColor, normalizeFlowBackground, normalizeFlowLineColor } from '../src/appium-recorder/flow-appearance';
|
|
3
4
|
import os from 'node:os';
|
|
4
5
|
import path from 'node:path';
|
|
5
6
|
import YAML from 'yaml';
|
|
6
7
|
import { appDataPath } from './paths';
|
|
7
8
|
import { loadModelConfigFromDb, saveModelConfigToDb } from './config-store';
|
|
8
9
|
import type { AiRecognitionModel } from '../src/appium-recorder/ai-recognition';
|
|
10
|
+
import { resolveAiPromptPresets } from '../src/appium-recorder/ai-prompt-presets';
|
|
11
|
+
import { resolveAiDeduplication, type AiDeduplicationConfig } from '../src/appium-recorder/ai-deduplication';
|
|
12
|
+
import type { MidsceneModelProvider } from '../src/config/midscene-model-presets';
|
|
9
13
|
|
|
10
14
|
export type AppConfig = {
|
|
11
|
-
appium: { model: AiRecognitionModel; flowBackgroundColor?: string };
|
|
15
|
+
appium: { model: AiRecognitionModel; aiPromptPresets?: AiPromptPreset[]; aiDeduplication?: AiDeduplicationConfig; flowBackgroundColor?: string; flowLineColor?: string; screenshotReport?: boolean };
|
|
12
16
|
runtime: {
|
|
13
17
|
androidSdkPath: string;
|
|
14
18
|
reportOutputPath: string;
|
|
15
19
|
};
|
|
16
20
|
midscene: {
|
|
17
21
|
model: {
|
|
18
|
-
provider:
|
|
22
|
+
provider: MidsceneModelProvider;
|
|
19
23
|
baseUrl: string;
|
|
20
24
|
apiKey: string;
|
|
21
25
|
name: string;
|
|
@@ -46,7 +50,7 @@ export class ConfigValidationError extends Error {
|
|
|
46
50
|
|
|
47
51
|
function defaultConfig(): AppConfig {
|
|
48
52
|
return {
|
|
49
|
-
appium: { model: { baseUrl: '', apiKey: '', name: '' } },
|
|
53
|
+
appium: { model: { baseUrl: '', apiKey: '', name: '' }, aiDeduplication: resolveAiDeduplication(), screenshotReport: false },
|
|
50
54
|
runtime: {
|
|
51
55
|
androidSdkPath: '',
|
|
52
56
|
reportOutputPath: '',
|
|
@@ -84,7 +88,11 @@ function normalizeConfig(config: Partial<AppConfig> | null | undefined): AppConf
|
|
|
84
88
|
return {
|
|
85
89
|
// 旧配置没有 Appium 模型时保留空值,不借用其他模型或改变已有配置。
|
|
86
90
|
appium: {
|
|
91
|
+
aiPromptPresets: resolveAiPromptPresets(config?.appium?.aiPromptPresets),
|
|
92
|
+
aiDeduplication: resolveAiDeduplication(config?.appium?.aiDeduplication),
|
|
93
|
+
screenshotReport: config?.appium?.screenshotReport === true,
|
|
87
94
|
flowBackgroundColor: normalizeFlowBackground(config?.appium?.flowBackgroundColor),
|
|
95
|
+
flowLineColor: normalizeFlowLineColor(config?.appium?.flowLineColor),
|
|
88
96
|
model: Object.fromEntries(['baseUrl', 'apiKey', 'name'].map((key) => {
|
|
89
97
|
const value = config?.appium?.model?.[key as keyof AiRecognitionModel];
|
|
90
98
|
return [key, typeof value === 'string' ? value.trim() : ''];
|
|
@@ -206,9 +214,16 @@ export function loadConfig(): AppConfig {
|
|
|
206
214
|
}
|
|
207
215
|
|
|
208
216
|
export function saveConfig(config: AppConfig) {
|
|
217
|
+
try { resolveAiPromptPresets(config.appium?.aiPromptPresets); }
|
|
218
|
+
catch (error) { throw new ConfigValidationError(error instanceof Error ? error.message : 'AI 识别预设提示词无效'); }
|
|
219
|
+
try { resolveAiDeduplication(config.appium?.aiDeduplication); }
|
|
220
|
+
catch (error) { throw new ConfigValidationError(error instanceof Error ? error.message : '截图去重配置无效'); }
|
|
209
221
|
if (config.appium?.flowBackgroundColor !== undefined && !isHexColor(config.appium.flowBackgroundColor)) {
|
|
210
222
|
throw new ConfigValidationError('流程背景色必须是有效的十六进制颜色(#RGB 或 #RRGGBB)');
|
|
211
223
|
}
|
|
224
|
+
if (config.appium?.flowLineColor !== undefined && !isHexColor(config.appium.flowLineColor)) {
|
|
225
|
+
throw new ConfigValidationError('连接线条颜色必须是有效的十六进制颜色(#RGB 或 #RRGGBB)');
|
|
226
|
+
}
|
|
212
227
|
const normalized = normalizeConfig(config);
|
|
213
228
|
validateRuntimeConfig(normalized);
|
|
214
229
|
cachedConfig = normalized;
|
package/server/http-api.ts
CHANGED
|
@@ -716,9 +716,16 @@ export function createApiMiddleware() {
|
|
|
716
716
|
return;
|
|
717
717
|
}
|
|
718
718
|
|
|
719
|
-
if (req.url === '/api/config' && req.method === 'POST') {
|
|
719
|
+
if ((req.url === '/api/config' || req.url === '/api/config/appium') && req.method === 'POST') {
|
|
720
720
|
try {
|
|
721
|
-
const
|
|
721
|
+
const parsed = await readBody<Partial<AppConfig>>(req);
|
|
722
|
+
if (req.url === '/api/config/appium' && (!parsed.appium || typeof parsed.appium !== 'object' || Array.isArray(parsed.appium))) {
|
|
723
|
+
throw new ConfigValidationError('Appium 配置无效');
|
|
724
|
+
}
|
|
725
|
+
const current = loadConfig();
|
|
726
|
+
const config = req.url === '/api/config/appium'
|
|
727
|
+
? { ...current, appium: { ...current.appium, ...parsed.appium, model: current.appium.model } }
|
|
728
|
+
: { ...current, runtime: parsed.runtime ?? current.runtime };
|
|
722
729
|
saveConfig(config);
|
|
723
730
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
724
731
|
res.end(JSON.stringify({ success: true }));
|
|
@@ -1089,9 +1096,10 @@ export function createApiMiddleware() {
|
|
|
1089
1096
|
if (req.url === '/api/test-model' && req.method === 'POST') {
|
|
1090
1097
|
try {
|
|
1091
1098
|
const parsed = await readBody<{
|
|
1099
|
+
save?: boolean;
|
|
1092
1100
|
modelKey?: 'midscene' | 'scriptOptimizer' | 'appium';
|
|
1093
1101
|
model?: {
|
|
1094
|
-
provider?: '
|
|
1102
|
+
provider?: import('../src/config/midscene-model-presets').MidsceneModelProvider;
|
|
1095
1103
|
baseUrl?: string;
|
|
1096
1104
|
apiKey?: string;
|
|
1097
1105
|
name?: string;
|
|
@@ -1100,6 +1108,13 @@ export function createApiMiddleware() {
|
|
|
1100
1108
|
}>(req);
|
|
1101
1109
|
const model = parsed.model || {};
|
|
1102
1110
|
|
|
1111
|
+
if (parsed.save && !['midscene', 'scriptOptimizer', 'appium'].includes(parsed.modelKey || '')) {
|
|
1112
|
+
throw new ConfigValidationError('请选择有效的模型配置');
|
|
1113
|
+
}
|
|
1114
|
+
if (parsed.save && parsed.modelKey === 'midscene' && !model.family?.trim()) {
|
|
1115
|
+
throw new ConfigValidationError('请填写 Model Family');
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1103
1118
|
const result = await testModelConnection({
|
|
1104
1119
|
provider: model.provider,
|
|
1105
1120
|
baseUrl: model.baseUrl || '',
|
|
@@ -1107,8 +1122,15 @@ export function createApiMiddleware() {
|
|
|
1107
1122
|
name: model.name || '',
|
|
1108
1123
|
family: model.family || '',
|
|
1109
1124
|
});
|
|
1125
|
+
if (parsed.save) {
|
|
1126
|
+
if (!result.ok) throw new Error('模型测试失败,未保存配置');
|
|
1127
|
+
const key = parsed.modelKey!;
|
|
1128
|
+
// 测试期间其他配置可能已保存,此时重新读取,只更新已通过测试的模型。
|
|
1129
|
+
const current = loadConfig();
|
|
1130
|
+
saveConfig({ ...current, [key]: { ...current[key], model: { ...current[key].model, ...model } } });
|
|
1131
|
+
}
|
|
1110
1132
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
1111
|
-
res.end(JSON.stringify(result));
|
|
1133
|
+
res.end(JSON.stringify({ ...result, saved: parsed.save === true }));
|
|
1112
1134
|
} catch (error) {
|
|
1113
1135
|
res.statusCode = 500;
|
|
1114
1136
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
package/server/model-tester.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
2
|
import OpenAI from 'openai';
|
|
3
|
+
import type { MidsceneModelProvider } from '../src/config/midscene-model-presets';
|
|
3
4
|
|
|
4
5
|
type ModelInput = {
|
|
5
|
-
provider?:
|
|
6
|
+
provider?: MidsceneModelProvider;
|
|
6
7
|
baseUrl: string;
|
|
7
8
|
apiKey: string;
|
|
8
9
|
name: string;
|
package/src/App.vue
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { isAiRecognitionModelConfigured } from './appium-recorder/ai-recognition';
|
|
3
|
+
import { resolveAiDeduplication } from './appium-recorder/ai-deduplication';
|
|
4
|
+
import { resolveAiPromptPresets } from './appium-recorder/ai-prompt-presets';
|
|
3
5
|
import { computed, onMounted, onUnmounted, reactive, ref, shallowRef, watch } from 'vue';
|
|
4
6
|
import { ElMessage, ElMessageBox } from 'element-plus';
|
|
5
7
|
import {
|
|
@@ -27,13 +29,14 @@ import {
|
|
|
27
29
|
type ScriptStep,
|
|
28
30
|
} from './script-generator';
|
|
29
31
|
import {
|
|
30
|
-
|
|
32
|
+
selectMidsceneModelProvider,
|
|
31
33
|
type MidsceneModelProvider,
|
|
32
34
|
} from './config/midscene-model-presets';
|
|
33
35
|
import * as api from './api';
|
|
36
|
+
import { useAppiumAutoSave } from './config/use-appium-auto-save';
|
|
34
37
|
import AutomationPage from './pages/AutomationPage.vue';
|
|
35
38
|
import AppiumPage from './appium-recorder/AppiumPage.vue';
|
|
36
|
-
import {
|
|
39
|
+
import { normalizeFlowBackground, normalizeFlowLineColor } from './appium-recorder/flow-appearance';
|
|
37
40
|
import ConfigPage from './pages/ConfigPage.vue';
|
|
38
41
|
import GeneratorPage from './pages/GeneratorPage.vue';
|
|
39
42
|
import type {
|
|
@@ -153,7 +156,7 @@ const appPresetForm = reactive({
|
|
|
153
156
|
});
|
|
154
157
|
|
|
155
158
|
const configForm = reactive<ConfigForm>({
|
|
156
|
-
appium: { model: { baseUrl: '', apiKey: '', name: '' } },
|
|
159
|
+
appium: { model: { baseUrl: '', apiKey: '', name: '' }, aiDeduplication: resolveAiDeduplication(), screenshotReport: false },
|
|
157
160
|
runtime: {
|
|
158
161
|
androidSdkPath: '',
|
|
159
162
|
reportOutputPath: '',
|
|
@@ -183,6 +186,12 @@ const lastCustomMidsceneModel = reactive({
|
|
|
183
186
|
family: '',
|
|
184
187
|
});
|
|
185
188
|
|
|
189
|
+
const appiumAutoSave = useAppiumAutoSave(configForm, async value => {
|
|
190
|
+
await api.saveAppiumConfig(value);
|
|
191
|
+
}, error => {
|
|
192
|
+
void ElMessageBox.alert(error instanceof Error ? error.message : 'Appium 配置自动保存失败', '自动保存失败', { type: 'error' }).catch(() => {});
|
|
193
|
+
});
|
|
194
|
+
|
|
186
195
|
const menuItems = [
|
|
187
196
|
{ key: 'generator', label: '测试脚本生成', icon: Operation },
|
|
188
197
|
{ key: 'automation', label: '自动化测试', icon: Monitor },
|
|
@@ -662,6 +671,12 @@ const loadConfig = async () => {
|
|
|
662
671
|
Object.assign(configForm.midscene.env, payload.midscene.env || {});
|
|
663
672
|
Object.assign(configForm.scriptOptimizer.model, payload.scriptOptimizer.model);
|
|
664
673
|
Object.assign(configForm.appium.model, payload.appium?.model || { baseUrl: '', apiKey: '', name: '' });
|
|
674
|
+
configForm.appium.screenshotReport = payload.appium?.screenshotReport === true;
|
|
675
|
+
configForm.appium.aiDeduplication = resolveAiDeduplication(payload.appium?.aiDeduplication);
|
|
676
|
+
configForm.appium.aiPromptPresets = resolveAiPromptPresets(payload.appium?.aiPromptPresets);
|
|
677
|
+
configForm.appium.flowBackgroundColor = normalizeFlowBackground(payload.appium?.flowBackgroundColor);
|
|
678
|
+
configForm.appium.flowLineColor = normalizeFlowLineColor(payload.appium?.flowLineColor);
|
|
679
|
+
appiumAutoSave.initialize();
|
|
665
680
|
aiRecognitionModelConfigured.value = isAiRecognitionModelConfigured(configForm.appium.model);
|
|
666
681
|
if (configForm.midscene.model.provider !== 'codex') {
|
|
667
682
|
rememberCustomMidsceneModel();
|
|
@@ -677,15 +692,10 @@ const rememberCustomMidsceneModel = () => {
|
|
|
677
692
|
};
|
|
678
693
|
|
|
679
694
|
const updateMidsceneModelProvider = (provider: MidsceneModelProvider) => {
|
|
680
|
-
if (provider
|
|
695
|
+
if (configForm.midscene.model.provider !== 'codex') {
|
|
681
696
|
rememberCustomMidsceneModel();
|
|
682
|
-
Object.assign(configForm.midscene.model, codexMidsceneModel);
|
|
683
|
-
} else {
|
|
684
|
-
Object.assign(configForm.midscene.model, {
|
|
685
|
-
provider: 'custom',
|
|
686
|
-
...lastCustomMidsceneModel,
|
|
687
|
-
});
|
|
688
697
|
}
|
|
698
|
+
Object.assign(configForm.midscene.model, selectMidsceneModelProvider(provider, configForm.midscene.model, lastCustomMidsceneModel));
|
|
689
699
|
modelTestStatus.midscene = '';
|
|
690
700
|
};
|
|
691
701
|
|
|
@@ -699,22 +709,15 @@ const getMidsceneModelConfigError = () => {
|
|
|
699
709
|
if (model.provider !== 'codex' && !model.apiKey.trim()) missing.push('API Key');
|
|
700
710
|
|
|
701
711
|
if (!missing.length) return '';
|
|
702
|
-
return `Midscene 模型配置不完整:缺少 ${missing.join('、')}。请在“参数配置 > Midscene配置 > Midscene 模型”中选择“使用 Codex
|
|
712
|
+
return `Midscene 模型配置不完整:缺少 ${missing.join('、')}。请在“参数配置 > Midscene配置 > Midscene 模型”中选择“使用 Codex”,或补齐自定义提供方后测试并保存。`;
|
|
703
713
|
};
|
|
704
714
|
|
|
705
715
|
const saveModelConfig = async () => {
|
|
706
|
-
if (!isHexColor(configForm.appium.flowBackgroundColor ?? DEFAULT_FLOW_BACKGROUND)) {
|
|
707
|
-
ElMessage.warning('流程背景色格式错误,请输入 #RGB 或 #RRGGBB');
|
|
708
|
-
return;
|
|
709
|
-
}
|
|
710
716
|
isSavingModelConfig.value = true;
|
|
711
717
|
errorMessage.value = '';
|
|
712
718
|
openActionDialog('保存参数配置');
|
|
713
719
|
try {
|
|
714
|
-
|
|
715
|
-
const snapshot = JSON.parse(JSON.stringify(configForm)) as ConfigForm;
|
|
716
|
-
await api.saveConfig(snapshot);
|
|
717
|
-
aiRecognitionModelConfigured.value = isAiRecognitionModelConfigured(snapshot.appium.model);
|
|
720
|
+
await api.saveConfig({ runtime: { ...configForm.runtime } });
|
|
718
721
|
closeActionDialog();
|
|
719
722
|
ElMessage.success('参数配置已保存');
|
|
720
723
|
} catch (error) {
|
|
@@ -744,9 +747,10 @@ const resetAppPresetForm = () => {
|
|
|
744
747
|
};
|
|
745
748
|
|
|
746
749
|
const saveAppPreset = async () => {
|
|
750
|
+
if (isSavingAppPreset.value) return;
|
|
751
|
+
const editing = Boolean(appPresetForm.id);
|
|
747
752
|
isSavingAppPreset.value = true;
|
|
748
753
|
errorMessage.value = '';
|
|
749
|
-
openActionDialog('保存 App 配置');
|
|
750
754
|
try {
|
|
751
755
|
const payload = await api.saveAppPreset({
|
|
752
756
|
id: appPresetForm.id || undefined,
|
|
@@ -758,11 +762,10 @@ const saveAppPreset = async () => {
|
|
|
758
762
|
form.appPresetId = payload.app.id;
|
|
759
763
|
}
|
|
760
764
|
resetAppPresetForm();
|
|
761
|
-
|
|
765
|
+
ElMessage.success(editing ? 'App 配置已更新' : 'App 已添加');
|
|
762
766
|
} catch (error) {
|
|
763
767
|
const message = error instanceof Error ? error.message : 'App 配置保存失败';
|
|
764
|
-
|
|
765
|
-
failActionDialog(message);
|
|
768
|
+
void ElMessageBox.alert(message, 'App 配置保存失败', { type: 'error' }).catch(() => {});
|
|
766
769
|
} finally {
|
|
767
770
|
isSavingAppPreset.value = false;
|
|
768
771
|
}
|
|
@@ -785,22 +788,27 @@ const removeAppPreset = async (id: string) => {
|
|
|
785
788
|
if (form.appPresetId === id) {
|
|
786
789
|
form.appPresetId = '';
|
|
787
790
|
}
|
|
791
|
+
if (appPresetForm.id === id) resetAppPresetForm();
|
|
788
792
|
} catch (error) {
|
|
789
|
-
|
|
793
|
+
void ElMessageBox.alert(error instanceof Error ? error.message : 'App 配置删除失败', '删除失败', { type: 'error' }).catch(() => {});
|
|
790
794
|
}
|
|
791
795
|
};
|
|
792
796
|
|
|
793
797
|
const testModel = async (key: 'midscene' | 'scriptOptimizer' | 'appium') => {
|
|
798
|
+
if (testingModelKey.value) return;
|
|
794
799
|
testingModelKey.value = key;
|
|
795
800
|
errorMessage.value = '';
|
|
796
801
|
modelTestStatus[key] = '';
|
|
797
|
-
const model = configForm[key].model;
|
|
802
|
+
const model = { ...configForm[key].model };
|
|
798
803
|
|
|
799
804
|
try {
|
|
800
|
-
const payload = await api.testModel({ modelKey: key, model });
|
|
801
|
-
|
|
805
|
+
const payload = await api.testModel({ modelKey: key, model, save: true });
|
|
806
|
+
if (!payload.saved) throw new Error('模型未保存,请重试');
|
|
807
|
+
modelTestStatus[key] = `测试通过,配置已保存${payload.content ? `:${payload.content}` : ''}`;
|
|
808
|
+
if (key === 'appium') aiRecognitionModelConfigured.value = isAiRecognitionModelConfigured(model);
|
|
809
|
+
ElMessage.success('测试通过,模型配置已保存');
|
|
802
810
|
} catch (error) {
|
|
803
|
-
modelTestStatus[key] = error instanceof Error ? error.message : '测试失败'
|
|
811
|
+
modelTestStatus[key] = `未保存:${error instanceof Error ? error.message : '测试失败'}`;
|
|
804
812
|
} finally {
|
|
805
813
|
testingModelKey.value = '';
|
|
806
814
|
}
|
|
@@ -1485,7 +1493,7 @@ onUnmounted(() => {
|
|
|
1485
1493
|
'page-body--appium': activeMenu === 'appium',
|
|
1486
1494
|
}"
|
|
1487
1495
|
>
|
|
1488
|
-
<el-alert v-if="activeMenu !== 'appium' && errorMessage" type="error" :closable="false" show-icon class="page-alert">
|
|
1496
|
+
<el-alert v-if="activeMenu !== 'appium' && activeMenu !== 'config' && errorMessage" type="error" :closable="false" show-icon class="page-alert">
|
|
1489
1497
|
<template #title>{{ errorMessage }}</template>
|
|
1490
1498
|
</el-alert>
|
|
1491
1499
|
|
|
@@ -1526,11 +1534,13 @@ onUnmounted(() => {
|
|
|
1526
1534
|
:is-saving-model-config="isSavingModelConfig"
|
|
1527
1535
|
:is-saving-app-preset="isSavingAppPreset"
|
|
1528
1536
|
:model-test-status="modelTestStatus"
|
|
1537
|
+
:appium-save-status="appiumAutoSave.status.value"
|
|
1529
1538
|
@test-model="testModel"
|
|
1530
1539
|
@save-model-config="saveModelConfig"
|
|
1531
1540
|
@save-app-preset="saveAppPreset"
|
|
1532
1541
|
@edit-app-preset="editAppPreset"
|
|
1533
1542
|
@delete-app-preset="removeAppPreset"
|
|
1543
|
+
@cancel-app-preset-edit="resetAppPresetForm"
|
|
1534
1544
|
@update-midscene-model-provider="updateMidsceneModelProvider"
|
|
1535
1545
|
/>
|
|
1536
1546
|
|
|
@@ -1569,7 +1579,9 @@ onUnmounted(() => {
|
|
|
1569
1579
|
:active="activeMenu === 'appium'"
|
|
1570
1580
|
:app-presets="appPresets"
|
|
1571
1581
|
:ai-recognition-model-configured="aiRecognitionModelConfigured"
|
|
1582
|
+
:ai-prompt-presets="configForm.appium.aiPromptPresets"
|
|
1572
1583
|
:flow-background-color="configForm.appium.flowBackgroundColor"
|
|
1584
|
+
:flow-line-color="configForm.appium.flowLineColor"
|
|
1573
1585
|
:device-actions="deviceActions"
|
|
1574
1586
|
:playground-available="playgroundAvailable"
|
|
1575
1587
|
:playground-device-id="playgroundDeviceId"
|