android-midscene-automation 0.1.43 → 0.2.2

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 (56) hide show
  1. package/{AI_RECOGNITION_GUIDE.md → AI/350/257/206/345/210/253/346/223/215/344/275/234/350/257/264/346/230/216.md} +78 -6
  2. package/CHANGELOG.md +13 -0
  3. package/FAQ.md +8 -8
  4. package/README.md +143 -17
  5. package/USAGE.md +120 -8
  6. package/package.json +3 -2
  7. package/server/analytics.test.ts +46 -0
  8. package/server/analytics.ts +24 -0
  9. package/server/api-safety-analytics.test.ts +57 -0
  10. package/server/appium-auto-save.test.ts +6 -0
  11. package/server/appium-recorder/ai-recognition.test.ts +27 -0
  12. package/server/appium-recorder/ai-recognition.ts +16 -4
  13. package/server/appium-recorder/appium-runner.ts +11 -1
  14. package/server/appium-recorder/appium-version.test.ts +55 -0
  15. package/server/appium-recorder/managed-appium.ts +37 -1
  16. package/server/appium-recorder/repository.ts +6 -2
  17. package/server/appium-recorder/routes.ts +2 -12
  18. package/server/appium-recorder/run-history.ts +11 -0
  19. package/server/config-access.test.ts +27 -0
  20. package/server/config-access.ts +27 -0
  21. package/server/http-api.ts +63 -54
  22. package/server/operations/repository.ts +8 -0
  23. package/server/remote-agents/routes.ts +2 -12
  24. package/server/request-body.test.ts +40 -0
  25. package/server/request-body.ts +48 -0
  26. package/src/App.vue +98 -742
  27. package/src/analytics/summary.ts +75 -0
  28. package/src/api.ts +11 -0
  29. package/src/appium-recorder/AppiumPage.vue +4 -1
  30. package/src/appium-recorder/ai-recognition.ts +7 -0
  31. package/src/appium-recorder/components/AiRecognitionSettings.vue +27 -0
  32. package/src/appium-recorder/flow-labels.ts +1 -1
  33. package/src/appium-recorder/types.ts +2 -1
  34. package/src/components/AppSidebar.vue +31 -0
  35. package/src/components/analytics/AnalysisCharts.vue +189 -0
  36. package/src/components/analytics/ReportAnalysis.vue +78 -0
  37. package/src/components/config/AiPromptPresetsSettings.vue +55 -5
  38. package/src/components/config/AppiumConfigPanel.vue +4 -1
  39. package/src/components/config/AppiumModelSettings.vue +1 -1
  40. package/src/components/config/BaseConfigPanel.vue +6 -2
  41. package/src/components/config/MidsceneConfigPanel.vue +4 -5
  42. package/src/components/help/HelpCenter.vue +185 -0
  43. package/src/components/help/MarkdownDocument.vue +139 -0
  44. package/src/composables/use-device-preview.ts +279 -0
  45. package/src/composables/use-initial-resource.ts +17 -0
  46. package/src/composables/use-script-execution.ts +277 -0
  47. package/src/config/credentials.ts +2 -0
  48. package/src/config/use-appium-auto-save.ts +22 -11
  49. package/src/config/use-model-config.ts +157 -0
  50. package/src/main.ts +2 -2
  51. package/src/pages/AnalysisPage.vue +60 -0
  52. package/src/pages/ConfigPage.vue +36 -5
  53. package/src/style.css +716 -91
  54. package/src/types.ts +7 -1
  55. package//346/265/201/347/250/213/350/212/202/347/202/271/346/223/215/344/275/234/350/257/264/346/230/216.md +408 -0
  56. package/src/ui-refresh.css +0 -677
@@ -0,0 +1,46 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { summarizeTests, type TestRun } from '../src/analytics/summary';
4
+
5
+ test('analytics includes unrun and historical scripts and excludes stopped/running from pass rate and average duration', () => {
6
+ const runs: TestRun[] = ['passed', 'failed', 'stopped', 'running'].map((status, i) => ({
7
+ scriptId: 'a', scriptName: 'old name', status: status as TestRun['status'], startedAt: `2026-09-2${i}T00:00:00Z`, durationMs: 1000 * (i + 1),
8
+ }));
9
+ runs.push({ scriptId: 'deleted', scriptName: '历史测试', status: 'passed', startedAt: '2026-09-01T00:00:00Z', durationMs: 10 });
10
+ const result = summarizeTests([{ id: 'a', name: '新名称' }, { id: 'b', name: '未执行' }], runs);
11
+ assert.equal(result.scripts, 2);
12
+ assert.equal(result.runs, 5);
13
+ assert.equal(result.testedScripts, 1);
14
+ assert.equal(result.passRate, 2 / 3);
15
+ const a = result.rows.find(row => row.id === 'a')!;
16
+ assert.equal(a.name, '新名称');
17
+ assert.equal(a.averageDurationMs, 1500);
18
+ assert.equal(a.passRate, 0.5);
19
+ assert.equal(a.lastStatus, 'running');
20
+ assert.equal(result.rows.find(row => row.id === 'b')?.passRate, null);
21
+ assert.equal(result.rows.find(row => row.id === 'deleted')?.archived, true);
22
+ const recent = summarizeTests([{ id: 'b', name: '未执行' }], runs, Date.parse('2026-09-22T00:00:00Z'));
23
+ assert.equal(recent.runs, 2);
24
+ assert.equal(recent.passRate, null);
25
+ assert.equal(recent.rows.find(row => row.id === 'a')?.averageDurationMs, null);
26
+ });
27
+
28
+
29
+ test('daily/monthly charts zero-fill calendar buckets and retain older runs in all-time totals', () => {
30
+ const now = new Date(2026, 0, 2, 12).getTime();
31
+ const runs: TestRun[] = [
32
+ { scriptId: 'a', scriptName: 'a', status: 'passed', startedAt: new Date(2026, 0, 2, 8).toISOString(), durationMs: 10 },
33
+ { scriptId: 'a', scriptName: 'a', status: 'failed', startedAt: new Date(2024, 0, 1).toISOString(), durationMs: 10 },
34
+ ];
35
+ const all = summarizeTests([], runs, 0, now);
36
+ assert.equal(all.runs, 2);
37
+ assert.equal(all.daily.length, 30);
38
+ assert.equal(all.monthly.length, 12);
39
+ assert.deepEqual(all.monthly.at(-1), { date: '2026-01', runs: 1 });
40
+ assert.equal(all.daily.reduce((sum, point) => sum + point.runs, 0), 1);
41
+ const recent = summarizeTests([], runs, new Date(2025, 11, 27).getTime(), now);
42
+ assert.equal(recent.daily.length, 7);
43
+ assert.equal(recent.monthly.length, 2);
44
+ assert.equal(recent.runs, 1);
45
+ assert.equal(recent.daily.at(-1)?.date, '2026-01-02');
46
+ });
@@ -0,0 +1,24 @@
1
+ import { summarizeTests, type AnalysisResponse, type TestStatus } from '../src/analytics/summary';
2
+ import { listScriptRecords } from './script-db';
3
+ import { listAppiumRecordedScripts } from './appium-recorder/repository';
4
+ import { listOperationAnalytics } from './operations/repository';
5
+ import { listRunAnalytics } from './appium-recorder/run-history';
6
+
7
+ export function getTestAnalytics(days = 0): AnalysisResponse {
8
+ const generatedAt = new Date().toISOString();
9
+ const start = new Date(generatedAt);
10
+ start.setDate(start.getDate() - Math.max(0, days - 1));
11
+ start.setHours(0, 0, 0, 0);
12
+ const since = days ? start.getTime() : 0;
13
+ const status: Record<string, TestStatus> = { succeeded: 'passed', failed: 'failed', cancelled: 'stopped', running: 'running' };
14
+ return {
15
+ generatedAt, days,
16
+ // Midscene's historical records identify scripts by name, not database ID.
17
+ midscene: summarizeTests(listScriptRecords().map(script => ({ id: script.name, name: script.name })), listOperationAnalytics().map(run => ({
18
+ scriptId: run.script_name, scriptName: run.script_name, startedAt: run.started_at,
19
+ durationMs: run.finished_at ? Date.parse(run.finished_at) - Date.parse(run.started_at) : null,
20
+ status: status[run.status],
21
+ })), since, Date.parse(generatedAt)),
22
+ appium: summarizeTests(listAppiumRecordedScripts().map(script => ({ id: script.id, name: script.name })), listRunAnalytics(), since, Date.parse(generatedAt)),
23
+ };
24
+ }
@@ -0,0 +1,57 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { createServer } from 'node:http';
4
+ import { mkdtemp, rm } from 'node:fs/promises';
5
+ import { tmpdir } from 'node:os';
6
+ import { join } from 'node:path';
7
+ import type { AnalysisResponse } from '../src/analytics/summary';
8
+
9
+ test('API rejects malformed requests safely and analytics reads all stored executions without a 100-record cap', async () => {
10
+ const root = await mkdtemp(join(tmpdir(), 'console-api-'));
11
+ const oldRoot = process.env.ANDROID_MIDSCENE_DATA_ROOT;
12
+ process.env.ANDROID_MIDSCENE_DATA_ROOT = root;
13
+ const { createApiMiddleware } = await import('./http-api');
14
+ const handler = createApiMiddleware();
15
+ const server = createServer((req, res) => handler(req, res, () => { res.statusCode = 404; res.end(); }));
16
+ try {
17
+ await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
18
+ const address = server.address();
19
+ assert.ok(address && typeof address === 'object');
20
+ const base = `http://127.0.0.1:${address.port}`;
21
+ for (const path of ['/api/config', '/api/config/appium', '/api/test-model', '/api/appium-recorder/scripts', '/api/generate']) {
22
+ assert.equal((await fetch(base + path, { method: 'POST', body: '{' })).status, 400, path);
23
+ }
24
+ assert.equal((await fetch(base + '/api/config', { headers: { Origin: 'https://foreign.example' } })).status, 403);
25
+ assert.equal((await fetch(base + '/api/config')).status, 200);
26
+ const { upsertScriptRecord } = await import('./script-db');
27
+ upsertScriptRecord({ name: 'Midscene 测试', promptTitle: '', sourcePrompt: '', code: 'private-code', filePath: '', steps: [] });
28
+ upsertScriptRecord({ name: '未执行', promptTitle: '', sourcePrompt: '', code: '', filePath: '', steps: [] });
29
+ const { initOperationRepository } = await import('./operations/repository');
30
+ const { runSql, sqlString } = await import('./storage/sqlite');
31
+ initOperationRepository();
32
+ const started = new Date(Date.now() - 10_000).toISOString();
33
+ const finished = new Date().toISOString();
34
+ runSql(Array.from({ length: 105 }, (_, i) => `INSERT INTO operations (id, kind, status, script_name, device_id, session_id, created_at, started_at, finished_at) VALUES ('op-${i}', 'script_run', '${i === 0 ? 'failed' : 'succeeded'}', 'Midscene 测试', '', '', ${sqlString(started)}, ${sqlString(started)}, ${sqlString(finished)});`).join('\n'));
35
+ const { saveAppiumRecordedScript } = await import('./appium-recorder/repository');
36
+ const script = saveAppiumRecordedScript({ name: 'Appium 测试', appPackage: 'example.test', steps: [] });
37
+ const { saveRunHistory } = await import('./appium-recorder/run-history');
38
+ saveRunHistory({ scriptId: script.id, scriptName: script.name, appPackage: 'example.test', appVersion: '1', deviceId: '', startedAt: started, durationMs: 4000, status: 'passed', nodes: [], output: 'private-output', frames: [] });
39
+ const response = await fetch(base + '/api/analytics');
40
+ const body = await response.text();
41
+ const summary = JSON.parse(body) as AnalysisResponse;
42
+ assert.equal(response.status, 200);
43
+ assert.equal(summary.midscene.runs, 105);
44
+ assert.equal(summary.midscene.scripts, 2);
45
+ assert.equal(summary.midscene.failed, 1);
46
+ assert.equal(summary.midscene.rows.find(row => row.name === '未执行')?.runs, 0);
47
+ assert.equal(summary.appium.runs, 1);
48
+ assert.equal(summary.appium.passRate, 1);
49
+ assert.equal(body.includes('private-'), false);
50
+ assert.equal((await fetch(base + '/api/analytics?days=invalid')).status, 400);
51
+ } finally {
52
+ server.closeAllConnections();
53
+ await new Promise<void>(resolve => server.close(() => resolve()));
54
+ if (oldRoot === undefined) delete process.env.ANDROID_MIDSCENE_DATA_ROOT; else process.env.ANDROID_MIDSCENE_DATA_ROOT = oldRoot;
55
+ await rm(root, { recursive: true, force: true });
56
+ }
57
+ });
@@ -52,7 +52,13 @@ test('loads quietly, serializes rapid edits, validates colors and surfaces save
52
52
  await delay(400);
53
53
  assert.equal(errors.length, 1);
54
54
  assert.match(auto.status.value, /失败/);
55
+ assert.equal(auto.failed.value, true);
56
+ assert.equal(auto.dirty.value, true);
55
57
  fail = false;
58
+ await auto.retry();
59
+ assert.equal(auto.failed.value, false);
60
+ assert.equal(auto.dirty.value, false);
61
+ assert.equal(submissions.at(-1)?.screenshotReport, true);
56
62
  form.appium.flowBackgroundColor = '#abcdef';
57
63
  await delay(400);
58
64
  assert.equal(auto.status.value, '已自动保存');
@@ -0,0 +1,27 @@
1
+ // Run: npx tsx --test server/appium-recorder/ai-recognition.test.ts
2
+ import assert from 'node:assert/strict';
3
+ import { test } from 'node:test';
4
+ import {
5
+ AiInvalidResultError,
6
+ parseAiRecognitionResult,
7
+ resolveAiInvalidResultFallback,
8
+ } from './ai-recognition';
9
+
10
+ test('valid AI boolean results keep their original branch', () => {
11
+ assert.deepEqual(parseAiRecognitionResult('{"result":true,"reason":"已出图"}'), { result: true, reason: '已出图' });
12
+ assert.deepEqual(parseAiRecognitionResult('{"result":false,"reason":"仍是黑屏"}'), { result: false, reason: '仍是黑屏' });
13
+ });
14
+
15
+ test('empty, invalid and indeterminate AI answers are classified as invalid results', () => {
16
+ for (const content of ['', 'not json', '{"result":"true"}', '{"result":null,"reason":"画面模糊"}']) {
17
+ assert.throws(() => parseAiRecognitionResult(content), AiInvalidResultError);
18
+ }
19
+ });
20
+
21
+ test('configured fallback applies only to invalid AI results', () => {
22
+ const invalid = new AiInvalidResultError('没有 true/false');
23
+ assert.equal(resolveAiInvalidResultFallback(invalid, 'yes'), true);
24
+ assert.equal(resolveAiInvalidResultFallback(invalid, 'no'), false);
25
+ assert.equal(resolveAiInvalidResultFallback(invalid), null);
26
+ assert.equal(resolveAiInvalidResultFallback(new Error('network failed'), 'yes'), null);
27
+ });
@@ -17,16 +17,28 @@ export function aiAnswerText(content: string | null | undefined) {
17
17
  return (content || '').replace(/<think>[\s\S]*?<\/think>/gi, '').trim().replace(/^```(?:json)?\s*([\s\S]*?)\s*```$/i, '$1').trim();
18
18
  }
19
19
 
20
+ export class AiInvalidResultError extends Error {
21
+ constructor(message: string) {
22
+ super(message);
23
+ this.name = 'AiInvalidResultError';
24
+ }
25
+ }
26
+
27
+ export function resolveAiInvalidResultFallback(error: unknown, branch?: 'yes' | 'no'): boolean | null {
28
+ if (!(error instanceof AiInvalidResultError) || (branch !== 'yes' && branch !== 'no')) return null;
29
+ return branch === 'yes';
30
+ }
31
+
20
32
  export function parseAiRecognitionResult(content: string | null | undefined): { result: boolean; reason: string } {
21
33
  // 只接受 JSON 布尔值;字符串 "false"、不确定或格式错误不能误走 false 分支。
22
34
  const text = aiAnswerText(content);
23
35
  let value: unknown;
24
- try { value = JSON.parse(text); } catch { throw new Error('AI 识别未返回有效 JSON 结果'); }
36
+ try { value = JSON.parse(text); } catch { throw new AiInvalidResultError('AI 识别未返回有效 JSON 结果'); }
25
37
  if (!value || typeof value !== 'object' || !('result' in value) || typeof value.result !== 'boolean') {
26
38
  if (value && typeof value === 'object' && 'result' in value && value.result === null) {
27
- throw new Error(`AI 无法判断,无法进入分支:${'reason' in value && typeof value.reason === 'string' ? value.reason.slice(0, 2000) : '未返回确定的判断结果'}`);
39
+ throw new AiInvalidResultError(`AI 无法判断:${'reason' in value && typeof value.reason === 'string' ? value.reason.slice(0, 2000) : '未返回确定的判断结果'}`);
28
40
  }
29
- throw new Error('AI 识别结果必须包含布尔值 result(true/false)');
41
+ throw new AiInvalidResultError('AI 识别结果必须包含布尔值 result(true/false)');
30
42
  }
31
43
  const reason = 'reason' in value && typeof value.reason === 'string' ? value.reason.trim() : '';
32
44
  return { result: value.result, reason: reason.slice(0, 2000) };
@@ -96,7 +108,7 @@ export async function recognizeDeviceScreen(input: {
96
108
  // 先记录正文再解析,格式错误或被截断的回答也可排查;不记录请求与鉴权信息。
97
109
  const content = (choice?.message.content || '').split(model.apiKey).join('[REDACTED]');
98
110
  input.onModelOutput?.(content || '(空响应)');
99
- if (choice?.finish_reason !== 'stop') throw new Error('AI 识别响应未完整返回');
111
+ if (choice?.finish_reason !== 'stop') throw new AiInvalidResultError('AI 识别响应未完整返回');
100
112
  if (input.aiBranchEnabled === false && observationConfig?.mode !== 'untilMatch') {
101
113
  const reason = aiAnswerText(content);
102
114
  if (!reason) throw new Error('AI 识别未返回回答内容');
@@ -10,7 +10,7 @@ import { adbScreenshotBase64 } from './screenshot';
10
10
  import { loadConfig } from '../config';
11
11
  import { timestampReplayLog } from './replay-log';
12
12
  import { checkImage } from './image-check';
13
- import { recognizeDeviceScreen } from './ai-recognition';
13
+ import { recognizeDeviceScreen, resolveAiInvalidResultFallback } from './ai-recognition';
14
14
  import { IMAGE_CHECK_MODES, type ImageCheckResult } from '../../src/appium-recorder/image-check';
15
15
  import { formatStageLog } from '../../src/appium-recorder/stage-log';
16
16
  import { openGalleryOnDevice } from './open-gallery';
@@ -1347,6 +1347,16 @@ async function replayFlowSteps(
1347
1347
  await captureReplayFrame(sessionId, step, index + 1, options.scriptName || '', 'stopped', '已终止');
1348
1348
  throw new ReplayStoppedError();
1349
1349
  }
1350
+ const invalidResultFallback = step.type === 'aiRecognition'
1351
+ ? resolveAiInvalidResultFallback(error, step.aiInvalidResultBranch)
1352
+ : null;
1353
+ if (invalidResultFallback !== null) {
1354
+ const status = `AI 未返回有效 true/false,按兜底配置进入 ${invalidResultFallback ? 'true' : 'false'} 分支`;
1355
+ lines.push(`[节点 ${index + 1}] ${status};${errorDetail(error)}`);
1356
+ await captureReplayFrame(sessionId, frameStep, index + 1, options.scriptName || '', 'after', status);
1357
+ index = traversal.branch(step, invalidResultFallback);
1358
+ continue;
1359
+ }
1350
1360
  if (error instanceof ConditionTimeoutError && (step.timeoutBranch === 'yes' || step.timeoutBranch === 'no')) {
1351
1361
  const branch = step.timeoutBranch;
1352
1362
  const status = `判断超时,按配置进入${branch === 'yes' ? '左' : '右'}侧分支(${flowBranchLabel(step, branch)})`;
@@ -0,0 +1,55 @@
1
+ import assert from 'node:assert/strict';
2
+ import { test } from 'node:test';
3
+ import childProcess from 'node:child_process';
4
+ import { syncBuiltinESMExports } from 'node:module';
5
+ import { getAppiumVersion } from './managed-appium';
6
+
7
+ test('Appium version uses the configured runtime and reports unavailable versions without a guessed fallback', async () => {
8
+ const original = { execFile: childProcess.execFile, fetch: globalThis.fetch };
9
+ const env = { executable: process.env.APPIUM_EXECUTABLE, url: process.env.APPIUM_SERVER_URL };
10
+ let output = '3.5.0-beta.1\n';
11
+ let commandError: Error | null = null;
12
+ let executions = 0;
13
+ try {
14
+ delete process.env.APPIUM_SERVER_URL;
15
+ process.env.APPIUM_EXECUTABLE = '/custom/appium';
16
+ childProcess.execFile = ((command, args, options, callback) => {
17
+ executions++;
18
+ assert.equal(command, '/custom/appium');
19
+ assert.deepEqual(args, ['--version']);
20
+ assert.equal(options.timeout, 5000);
21
+ queueMicrotask(() => callback(commandError, output, ''));
22
+ return {};
23
+ }) as typeof childProcess.execFile;
24
+ syncBuiltinESMExports();
25
+ assert.equal((await getAppiumVersion()).version, '3.5.0-beta.1');
26
+ output = 'Warning: Node 24.19.0 is unsupported';
27
+ assert.equal((await getAppiumVersion()).version, null, 'do not mistake diagnostic output for the Appium version');
28
+ commandError = Object.assign(new Error('missing'), { code: 'ENOENT' });
29
+ assert.match((await getAppiumVersion()).message, /未检测到/);
30
+ commandError = new Error('timeout');
31
+ assert.match((await getAppiumVersion()).message, /读取.*失败/);
32
+
33
+ process.env.APPIUM_SERVER_URL = 'http://appium.example/wd/hub/';
34
+ const localExecutions = executions;
35
+ globalThis.fetch = async (url, options) => {
36
+ assert.equal(url, 'http://appium.example/wd/hub/status');
37
+ assert.ok(options?.signal);
38
+ return Response.json({ value: { ready: true, build: { version: '2.19.0' } } });
39
+ };
40
+ assert.deepEqual(await getAppiumVersion(), { version: '2.19.0', source: 'server', message: '当前配置的 Appium 服务' });
41
+ globalThis.fetch = async () => Response.json({ value: {} });
42
+ assert.equal((await getAppiumVersion()).version, null);
43
+ globalThis.fetch = async () => new Response('unavailable', { status: 503 });
44
+ assert.match((await getAppiumVersion()).message, /无法连接/);
45
+ globalThis.fetch = async () => { throw new Error('offline'); };
46
+ assert.equal((await getAppiumVersion()).version, null);
47
+ assert.equal(executions, localExecutions, 'external service failures must not show an unrelated local version');
48
+ } finally {
49
+ childProcess.execFile = original.execFile;
50
+ globalThis.fetch = original.fetch;
51
+ syncBuiltinESMExports();
52
+ if (env.executable === undefined) delete process.env.APPIUM_EXECUTABLE; else process.env.APPIUM_EXECUTABLE = env.executable;
53
+ if (env.url === undefined) delete process.env.APPIUM_SERVER_URL; else process.env.APPIUM_SERVER_URL = env.url;
54
+ }
55
+ });
@@ -1,5 +1,41 @@
1
1
  import { execFile, spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
2
2
  import { createServer } from 'node:net';
3
+ import type { AppiumVersionInfo } from '../../src/types';
4
+
5
+ function appiumExecutable() {
6
+ return process.env.APPIUM_EXECUTABLE?.trim() || (process.platform === 'win32' ? 'appium.cmd' : 'appium');
7
+ }
8
+
9
+ export async function getAppiumVersion(): Promise<AppiumVersionInfo> {
10
+ const source = usesManagedAppiumServer() ? 'local' : 'server';
11
+ try {
12
+ let output: unknown;
13
+ if (source === 'server') {
14
+ const url = process.env.APPIUM_SERVER_URL!.trim().replace(/\/+$/, '');
15
+ const response = await fetch(`${url}/status`, { signal: AbortSignal.timeout(3000) });
16
+ if (!response.ok) throw new Error('Appium status request failed');
17
+ const payload = await response.json() as { value?: { build?: { version?: unknown } } } | null;
18
+ output = payload?.value?.build?.version;
19
+ } else {
20
+ output = await new Promise<string>((resolve, reject) => {
21
+ execFile(appiumExecutable(), ['--version'], {
22
+ timeout: 5000, maxBuffer: 64 * 1024, windowsHide: true, shell: process.platform === 'win32',
23
+ }, (error, stdout) => error ? reject(error) : resolve(stdout));
24
+ });
25
+ }
26
+ const version = typeof output === 'string'
27
+ ? output.match(/^\s*v?(\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?)\s*$/m)?.[1]
28
+ : undefined;
29
+ return { version: version ?? null, source, message: version
30
+ ? (source === 'local' ? '本机已安装 · Android 自动化引擎' : '当前配置的 Appium 服务')
31
+ : '未返回有效版本号,请检查 Appium 安装或服务状态' };
32
+ } catch (error) {
33
+ const missing = error instanceof Error && 'code' in error && error.code === 'ENOENT';
34
+ return { version: null, source, message: source === 'server'
35
+ ? '暂时无法连接 Appium 服务,请检查服务后刷新'
36
+ : missing ? '未检测到 Appium,请安装或检查可执行文件路径' : '读取 Appium 版本失败,请检查安装后刷新' };
37
+ }
38
+ }
3
39
 
4
40
  type ManagedAppiumServer = {
5
41
  serverUrl: string;
@@ -110,7 +146,7 @@ export async function startManagedAppiumServer(
110
146
  ): Promise<ManagedAppiumServer> {
111
147
  const port = await reserveLocalPort();
112
148
  const serverUrl = `http://127.0.0.1:${port}`;
113
- const executable = process.env.APPIUM_EXECUTABLE?.trim() || (process.platform === 'win32' ? 'appium.cmd' : 'appium');
149
+ const executable = appiumExecutable();
114
150
  const args = [
115
151
  '--address',
116
152
  '127.0.0.1',
@@ -1,4 +1,4 @@
1
- import { validateAiObservation, type AiObservationConfig } from '../../src/appium-recorder/ai-recognition';
1
+ import { validateAiInvalidResultBranch, validateAiObservation, type AiInvalidResultBranch, type AiObservationConfig } from '../../src/appium-recorder/ai-recognition';
2
2
  import {
3
3
  createId,
4
4
  querySql,
@@ -103,6 +103,7 @@ export type AppiumRecordedStepRecord = {
103
103
  timeoutMs?: number;
104
104
  aiTimeoutEnabled?: boolean;
105
105
  aiBranchEnabled?: boolean;
106
+ aiInvalidResultBranch?: AiInvalidResultBranch;
106
107
  aiObservation?: AiObservationConfig;
107
108
  timeoutBranch?: 'stop' | 'yes' | 'no';
108
109
  longPressMode?: 'element' | 'coordinates';
@@ -382,7 +383,10 @@ export function saveAppiumRecordedScript(input: {
382
383
  if (input.variables !== undefined) validateVariables(input.variables);
383
384
  for (const step of steps) {
384
385
  // 读取旧脚本必须容错,让用户能打开并修正无效配置;仅在写入时校验。
385
- if (step.type === 'aiRecognition') validateAiObservation(step.aiObservation);
386
+ if (step.type === 'aiRecognition') {
387
+ validateAiObservation(step.aiObservation);
388
+ validateAiInvalidResultBranch(step.aiInvalidResultBranch);
389
+ }
386
390
  if (step.type === 'imageCheck') validateImageCheck(step.imageCheck);
387
391
  if (step.type === 'extractVariable') validateExtraction(step.extractVariable);
388
392
  if (step.type === 'runScript') { validateVariables(step.parameters); validateReturns(step.returns); }
@@ -1,3 +1,4 @@
1
+ import { readBody, requestErrorStatus } from '../request-body';
1
2
  import type { AiObservationConfig } from '../../src/appium-recorder/ai-recognition';
2
3
  import { execFile } from 'node:child_process';
3
4
  import { sendReplayVideo } from './video-response';
@@ -43,17 +44,6 @@ function sendStreamEvent(res: ServerResponse, payload: unknown) {
43
44
  res.write(`${JSON.stringify(payload)}\n`);
44
45
  }
45
46
 
46
- async function readBody<T>(req: IncomingMessage) {
47
- let body = '';
48
- req.on('data', (chunk) => {
49
- body += chunk;
50
- });
51
- return await new Promise<T>((resolve) => {
52
- req.on('end', () => {
53
- resolve(JSON.parse(body || '{}') as T);
54
- });
55
- });
56
- }
57
47
 
58
48
  function execFileText(command: string, args: string[] = []) {
59
49
  return new Promise<string>((resolve, reject) => {
@@ -504,7 +494,7 @@ export async function handleAppiumRecorderRequest(
504
494
  sendStreamEvent(res, { type: 'error', message });
505
495
  res.end();
506
496
  } else {
507
- sendJson(res, { message }, 500);
497
+ sendJson(res, { message }, requestErrorStatus(error));
508
498
  }
509
499
  return true;
510
500
  }
@@ -40,3 +40,14 @@ export function deleteRunHistory(scriptId: string, id: string) {
40
40
  }
41
41
  runSql(`PRAGMA secure_delete=ON; DELETE FROM appium_run_history WHERE script_id=${sqlString(scriptId)} AND id=${sqlString(id)};`);
42
42
  }
43
+
44
+ export function listRunAnalytics() {
45
+ ensureTable();
46
+ return querySql<{ scriptId: string; scriptName: string; startedAt: string; durationMs: number; status: RunSummary['status'] }>(`
47
+ SELECT script_id AS scriptId, started_at AS startedAt,
48
+ json_extract(summary, '$.scriptName') AS scriptName,
49
+ json_extract(summary, '$.durationMs') AS durationMs,
50
+ json_extract(summary, '$.status') AS status
51
+ FROM appium_run_history;
52
+ `);
53
+ }
@@ -0,0 +1,27 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import type { IncomingMessage } from 'node:http';
4
+ import { canManageConfig, publicConfig, resolveSavedModelKey } from './config-access';
5
+ import { SAVED_API_KEY } from '../src/config/credentials';
6
+ import type { AppConfig } from './config';
7
+
8
+ test('configuration access requires a loopback client and matching browser origin', () => {
9
+ const request = (address: string, origin?: string) => ({ socket: { remoteAddress: address }, headers: { host: 'localhost:5173', origin } }) as IncomingMessage;
10
+ assert.equal(canManageConfig(request('192.0.2.10')), false);
11
+ assert.equal(canManageConfig(request('127.0.0.1', 'https://foreign.example')), false);
12
+ assert.equal(canManageConfig(request('::1', 'http://localhost:5173')), true);
13
+ assert.equal(canManageConfig(request('::ffff:127.0.0.1')), true);
14
+ });
15
+
16
+ test('public config masks every key and server env without modifying stored values; masked keys cannot move to another endpoint', () => {
17
+ const model = { baseUrl: 'https://model.example/v1', apiKey: 'private-secret', name: 'model' };
18
+ const stored = { runtime: { androidSdkPath: '', reportOutputPath: '' }, midscene: { model: { ...model, provider: 'custom', family: 'gpt-5' }, env: { SECRET: 'secret-env' } }, scriptOptimizer: { model }, appium: { model, promptOptimizer: { model } } } as AppConfig;
19
+ const output = publicConfig(stored);
20
+ assert.equal(JSON.stringify(output).includes('private-secret'), false);
21
+ assert.deepEqual(output.midscene.env, {});
22
+ assert.equal(stored.midscene.model.apiKey, 'private-secret');
23
+ assert.equal(resolveSavedModelKey({ ...model, apiKey: SAVED_API_KEY }, model), 'private-secret');
24
+ assert.throws(() => resolveSavedModelKey({ ...model, baseUrl: 'https://other.example', apiKey: SAVED_API_KEY }, model), /重新输入/);
25
+ assert.throws(() => resolveSavedModelKey({ ...model, provider: 'codex', apiKey: SAVED_API_KEY }, model), /重新输入/);
26
+ assert.equal(resolveSavedModelKey({ ...model, apiKey: 'replacement' }, model), 'replacement');
27
+ });
@@ -0,0 +1,27 @@
1
+ import type { IncomingMessage } from 'node:http';
2
+ import type { AppConfig } from './config';
3
+ import { SAVED_API_KEY } from '../src/config/credentials';
4
+
5
+ export function canManageConfig(req: IncomingMessage) {
6
+ if (!['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(req.socket.remoteAddress || '')) return false;
7
+ if (!req.headers.origin) return true;
8
+ try { return new URL(req.headers.origin).host === req.headers.host; } catch { return false; }
9
+ }
10
+
11
+ export function publicConfig(config: AppConfig): AppConfig {
12
+ const result = structuredClone(config);
13
+ for (const model of [result.midscene.model, result.scriptOptimizer.model, result.appium.model, result.appium.promptOptimizer?.model]) {
14
+ if (model?.apiKey) model.apiKey = SAVED_API_KEY;
15
+ }
16
+ // Runtime environment overrides are only consumed by the server.
17
+ result.midscene.env = {};
18
+ return result;
19
+ }
20
+
21
+ export function resolveSavedModelKey(model: { apiKey?: string; baseUrl?: string; provider?: string }, stored?: { apiKey: string; baseUrl: string; provider?: string }) {
22
+ if (model.apiKey !== SAVED_API_KEY) return model.apiKey || '';
23
+ if (!stored || model.baseUrl?.trim().replace(/\/+$/, '') !== stored.baseUrl.trim().replace(/\/+$/, '') || model.provider !== stored.provider) {
24
+ throw new Error('模型连接地址或提供方已改变,请重新输入 API Key');
25
+ }
26
+ return stored.apiKey;
27
+ }