android-midscene-automation 0.2.1 → 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.
- package/CHANGELOG.md +9 -1
- package/README.md +9 -1
- package/package.json +1 -1
- package/server/analytics.test.ts +46 -0
- package/server/analytics.ts +24 -0
- package/server/api-safety-analytics.test.ts +57 -0
- package/server/appium-auto-save.test.ts +6 -0
- package/server/appium-recorder/appium-version.test.ts +55 -0
- package/server/appium-recorder/managed-appium.ts +37 -1
- package/server/appium-recorder/routes.ts +2 -12
- package/server/appium-recorder/run-history.ts +11 -0
- package/server/config-access.test.ts +27 -0
- package/server/config-access.ts +27 -0
- package/server/http-api.ts +63 -54
- package/server/operations/repository.ts +8 -0
- package/server/remote-agents/routes.ts +2 -12
- package/server/request-body.test.ts +40 -0
- package/server/request-body.ts +48 -0
- package/src/App.vue +96 -758
- package/src/analytics/summary.ts +75 -0
- package/src/api.ts +11 -0
- package/src/appium-recorder/AppiumPage.vue +4 -1
- package/src/components/AppSidebar.vue +31 -0
- package/src/components/analytics/AnalysisCharts.vue +189 -0
- package/src/components/analytics/ReportAnalysis.vue +78 -0
- package/src/components/config/AppiumConfigPanel.vue +4 -1
- package/src/components/config/AppiumModelSettings.vue +1 -1
- package/src/components/config/BaseConfigPanel.vue +6 -2
- package/src/components/config/MidsceneConfigPanel.vue +4 -5
- package/src/components/help/HelpCenter.vue +185 -0
- package/src/composables/use-device-preview.ts +279 -0
- package/src/composables/use-initial-resource.ts +17 -0
- package/src/composables/use-script-execution.ts +277 -0
- package/src/config/credentials.ts +2 -0
- package/src/config/use-appium-auto-save.ts +22 -11
- package/src/config/use-model-config.ts +157 -0
- package/src/main.ts +2 -2
- package/src/pages/AnalysisPage.vue +60 -0
- package/src/pages/ConfigPage.vue +36 -5
- package/src/style.css +711 -123
- package/src/types.ts +7 -1
- package/src/components/help/HelpCenterDialog.vue +0 -160
- package/src/ui-refresh.css +0 -677
package/CHANGELOG.md
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
# 更新记录
|
|
2
|
+
## v0.2.2
|
|
3
|
+
- 更新 Vben 风格的浅色后台界面:蓝色主色、白色卡片、浅灰背景、可折叠侧边栏与顶部页签。
|
|
4
|
+
- 新增“概览 → 分析页”,根据已保存脚本和真实执行记录分别汇总 Midscene、Appium 测试数据。
|
|
5
|
+
- 修复配置加载失败后出现空白可保存表单的问题,增加独立错误提示和重试;后端断线后自动尝试重连并支持手动重连,保留页面编辑内容。
|
|
6
|
+
- Appium 配置自动保存失败后保留最新待保存内容并提供“重试保存”;修复切换或关闭 Appium 页签后,离开页面时未保存草稿保护失效的问题。
|
|
7
|
+
- Appium 顶部工具栏改为随页面布局排列,窄屏自动换行,避免遮挡面包屑;统一全站样式与中文组件文案,合并重复主题声明。
|
|
8
|
+
- 完善隔离数据环境下的浏览器回归测试,以及请求校验、配置保护、自动保存重试和统计口径测试。
|
|
9
|
+
|
|
2
10
|
## v0.2.1
|
|
3
11
|
- 侧边栏底部新增“帮助与版本”入口。
|
|
4
12
|
- AI 识别预设提示词支持编辑测试场景名称和提示词内容,默认预设与自定义预设均可修改并自动保存。
|
|
5
|
-
- AI 识别启用分支后可勾选“启用兜底”,再选择进入左侧 true 或右侧 false
|
|
13
|
+
- AI 识别启用分支后可勾选“启用兜底”,再选择进入左侧 true 或右侧 false 分支;正常识别结果不受影响。
|
|
6
14
|
|
|
7
15
|
## v0.1.43
|
|
8
16
|
- 新增独立的“提示词优化模型”,统一用于优化 AI 识别提示词、报告提示词和生成回放测试报告总结;AI 识别与回放报告均可输入简要要求后由模型补充提示词,模型测试成功改为弹窗提示。
|
package/README.md
CHANGED
|
@@ -1358,10 +1358,18 @@ Linux: ~/.local/share/android-midscene-automation
|
|
|
1358
1358
|
|
|
1359
1359
|
# 项目更新记录
|
|
1360
1360
|
|
|
1361
|
+
## v0.2.2
|
|
1362
|
+
- 更新 Vben 风格的浅色后台界面:蓝色主色、白色卡片、浅灰背景、可折叠侧边栏与顶部页签。
|
|
1363
|
+
- 新增“概览 → 分析页”,根据已保存脚本和真实执行记录分别汇总 Midscene、Appium 测试数据。
|
|
1364
|
+
- 修复配置加载失败后出现空白可保存表单的问题,增加独立错误提示和重试;后端断线后自动尝试重连并支持手动重连,保留页面编辑内容。
|
|
1365
|
+
- Appium 配置自动保存失败后保留最新待保存内容并提供“重试保存”;修复切换或关闭 Appium 页签后,离开页面时未保存草稿保护失效的问题。
|
|
1366
|
+
- Appium 顶部工具栏改为随页面布局排列,窄屏自动换行,避免遮挡面包屑;统一全站样式与中文组件文案,合并重复主题声明。
|
|
1367
|
+
- 完善隔离数据环境下的浏览器回归测试,以及请求校验、配置保护、自动保存重试和统计口径测试。
|
|
1368
|
+
|
|
1361
1369
|
## v0.2.1
|
|
1362
1370
|
- 侧边栏底部新增“帮助与版本”入口。
|
|
1363
1371
|
- AI 识别预设提示词支持编辑测试场景名称和提示词内容,默认预设与自定义预设均可修改并自动保存。
|
|
1364
|
-
- AI 识别启用分支后可勾选“启用兜底”,再选择进入左侧 true 或右侧 false
|
|
1372
|
+
- AI 识别启用分支后可勾选“启用兜底”,再选择进入左侧 true 或右侧 false 分支;正常识别结果不受影响。
|
|
1365
1373
|
|
|
1366
1374
|
## v0.1.43
|
|
1367
1375
|
- 新增独立的“提示词优化模型”,统一用于优化 AI 识别提示词、报告提示词和生成回放测试报告总结;AI 识别与回放报告均可输入简要要求后由模型补充提示词,模型测试成功改为弹窗提示。
|
package/package.json
CHANGED
|
@@ -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,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 =
|
|
149
|
+
const executable = appiumExecutable();
|
|
114
150
|
const args = [
|
|
115
151
|
'--address',
|
|
116
152
|
'127.0.0.1',
|
|
@@ -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 },
|
|
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
|
+
}
|