android-midscene-automation 0.2.1 → 0.2.3
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 +15 -1
- package/README.md +15 -1
- package/package.json +1 -1
- package/remote-agent/index.ts +50 -22
- 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-runner.ts +30 -9
- package/server/appium-recorder/appium-version.test.ts +55 -0
- package/server/appium-recorder/managed-appium.ts +37 -1
- package/server/appium-recorder/managed-cleanup.test.ts +38 -8
- package/server/appium-recorder/replay-lifecycle.test.ts +123 -0
- package/server/appium-recorder/routes.ts +8 -18
- 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/protocol.ts +1 -0
- package/server/remote-agents/registry.test.ts +65 -0
- package/server/remote-agents/registry.ts +60 -5
- package/server/remote-agents/replay-integration.test.ts +79 -0
- package/server/remote-agents/routes.ts +12 -15
- 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
|
@@ -8,12 +8,14 @@ import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
|
|
|
8
8
|
import { tmpdir } from 'node:os';
|
|
9
9
|
import { join } from 'node:path';
|
|
10
10
|
|
|
11
|
-
test('
|
|
11
|
+
test('replay survives command idle periods and cleans sessions after success, abort and creation failure', async () => {
|
|
12
12
|
const root = await mkdtemp(join(tmpdir(), 'managed-cleanup-'));
|
|
13
13
|
const env = { ...process.env };
|
|
14
14
|
const original = { execFile: childProcess.execFile, spawn: childProcess.spawn, fetch: globalThis.fetch };
|
|
15
15
|
const calls: string[][] = [];
|
|
16
16
|
let mode = 'success', stopped = false;
|
|
17
|
+
let elapsedMs = 0, lastCommandMs = 0, commandTimeoutMs = 60_000;
|
|
18
|
+
let keyCommands = 0, deletedSessions = 0;
|
|
17
19
|
let controller = new AbortController();
|
|
18
20
|
try {
|
|
19
21
|
const sdk = join(root, 'sdk');
|
|
@@ -44,8 +46,20 @@ test('owned device instrumentation is cleaned after success, abort and failed se
|
|
|
44
46
|
const path = new URL(String(input)).pathname;
|
|
45
47
|
if (path === '/session' && init?.method === 'POST') {
|
|
46
48
|
if (mode === 'creation-failure') return Response.json({ value: { error: 'session not created', message: 'creation failed' } }, { status: 500 });
|
|
49
|
+
const capabilities = JSON.parse(String(init.body)).capabilities.alwaysMatch;
|
|
50
|
+
commandTimeoutMs = (capabilities['appium:newCommandTimeout'] ?? 60) * 1000;
|
|
51
|
+
lastCommandMs = elapsedMs;
|
|
47
52
|
return Response.json({ value: { sessionId: 'test-session' } });
|
|
48
53
|
}
|
|
54
|
+
if (path.endsWith('/press_keycode')) {
|
|
55
|
+
keyCommands++;
|
|
56
|
+
// Model Appium's idle expiry without waiting a real minute or touching a device.
|
|
57
|
+
if (commandTimeoutMs && elapsedMs - lastCommandMs > commandTimeoutMs) {
|
|
58
|
+
return Response.json({ value: { error: 'invalid session id', message: 'New Command Timeout expired' } }, { status: 404 });
|
|
59
|
+
}
|
|
60
|
+
lastCommandMs = elapsedMs;
|
|
61
|
+
}
|
|
62
|
+
if (path === '/session/test-session' && init?.method === 'DELETE') deletedSessions++;
|
|
49
63
|
return Response.json({ value: {} });
|
|
50
64
|
};
|
|
51
65
|
const { replayAppiumScript } = await import('./appium-runner');
|
|
@@ -53,19 +67,35 @@ test('owned device instrumentation is cleaned after success, abort and failed se
|
|
|
53
67
|
id: 'cleanup', name: 'cleanup', deviceId: 'test-device', appPackage: '', appActivity: '', createdAt: '', updatedAt: '',
|
|
54
68
|
steps: [{ id: 'log', type: 'log' as const, label: 'log', value: 'ok' }],
|
|
55
69
|
};
|
|
56
|
-
for (mode of ['success', 'abort', 'creation-failure', 'cleanup-failure', 'external']) {
|
|
70
|
+
for (mode of ['success', 'long-idle', 'abort', 'creation-failure', 'cleanup-failure', 'external', 'external-abort']) {
|
|
57
71
|
calls.length = 0;
|
|
72
|
+
elapsedMs = lastCommandMs = keyCommands = deletedSessions = 0;
|
|
58
73
|
controller = new AbortController();
|
|
59
|
-
|
|
60
|
-
const
|
|
61
|
-
|
|
74
|
+
const external = mode.startsWith('external');
|
|
75
|
+
const aborted = mode.endsWith('abort');
|
|
76
|
+
const longIdle = mode.endsWith('long-idle');
|
|
77
|
+
if (external) process.env.APPIUM_SERVER_URL = 'http://external.invalid';
|
|
78
|
+
else delete process.env.APPIUM_SERVER_URL;
|
|
79
|
+
const replayScript = longIdle ? { ...script, steps: [
|
|
80
|
+
...script.steps,
|
|
81
|
+
{ id: 'home', type: 'key' as const, label: 'Home', keyCode: 3 },
|
|
82
|
+
] } : script;
|
|
83
|
+
const result = await replayAppiumScript(replayScript, 'test-device', line => {
|
|
84
|
+
// ADB operations and model inference do not reset Appium's command timer.
|
|
85
|
+
if (longIdle && line.includes('[节点 1] 完成')) elapsedMs += 120_000;
|
|
86
|
+
if (aborted && line.includes('Appium session 已创建')) controller.abort();
|
|
62
87
|
}, controller.signal);
|
|
63
|
-
assert.equal(calls.length,
|
|
64
|
-
|
|
88
|
+
assert.equal(calls.length, external ? 0 : 2);
|
|
89
|
+
assert.equal(deletedSessions, mode === 'creation-failure' || (aborted && !external) ? 0 : 1, 'release the replay session even when idle expiry is disabled');
|
|
90
|
+
if (!external) {
|
|
65
91
|
assert.deepEqual(calls.map(args => args.slice(0, 5)), Array(2).fill(['-s', 'test-device', 'shell', 'am', 'force-stop']));
|
|
66
92
|
assert.deepEqual(calls.map(args => args.at(-1)).sort(), ['io.appium.uiautomator2.server', 'io.appium.uiautomator2.server.test']);
|
|
67
93
|
}
|
|
68
|
-
if (
|
|
94
|
+
if (longIdle) {
|
|
95
|
+
assert.equal(keyCommands, 1, 'execute Home after the long interval without Appium commands');
|
|
96
|
+
assert.equal(result.success, true, result.output);
|
|
97
|
+
}
|
|
98
|
+
if (aborted) assert.equal(result.stopped, true);
|
|
69
99
|
if (mode === 'creation-failure') assert.equal(result.success, false);
|
|
70
100
|
if (mode === 'cleanup-failure') assert.match(result.output, /UiAutomator2 清理失败/);
|
|
71
101
|
}
|
|
@@ -0,0 +1,123 @@
|
|
|
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 { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { createServer } from 'node:http';
|
|
9
|
+
import type { AppiumRecordedScriptRecord, AppiumRecordedStepRecord } from './repository';
|
|
10
|
+
|
|
11
|
+
test('ADB cancellation and deadlines cover actions; external sessions survive idle work and expire after creation cancellation', async () => {
|
|
12
|
+
const root = await mkdtemp(join(tmpdir(), 'replay-lifecycle-'));
|
|
13
|
+
const env = { ...process.env };
|
|
14
|
+
const original = { execFile: childProcess.execFile, fetch: globalThis.fetch, setInterval: globalThis.setInterval };
|
|
15
|
+
let controller = new AbortController();
|
|
16
|
+
let mode = '', deletes = 0, launchedAfterAbort = false;
|
|
17
|
+
let requestCount = 0;
|
|
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, APPIUM_SERVER_URL: 'http://external.invalid' });
|
|
23
|
+
childProcess.execFile = ((_cmd, args: string[], options, callback) => {
|
|
24
|
+
requestCount++;
|
|
25
|
+
if (mode === 'foreground-abort' && args.includes('activities')) {
|
|
26
|
+
queueMicrotask(() => { controller.abort(); callback(new Error('aborted'), '', ''); });
|
|
27
|
+
} else if (args.includes('monkey') || args.includes('clear') || args.includes('input')) {
|
|
28
|
+
launchedAfterAbort ||= controller.signal.aborted;
|
|
29
|
+
assert.ok(options.timeout > 0, 'every device action has a deadline');
|
|
30
|
+
assert.equal(options.signal, controller.signal);
|
|
31
|
+
if (mode === 'timeout') {
|
|
32
|
+
queueMicrotask(() => callback(new Error('ADB command timed out'), '', ''));
|
|
33
|
+
} else {
|
|
34
|
+
options.signal.addEventListener('abort', () => callback(new Error('aborted'), '', ''), { once: true });
|
|
35
|
+
queueMicrotask(() => controller.abort());
|
|
36
|
+
}
|
|
37
|
+
} else queueMicrotask(() => callback(null, '', ''));
|
|
38
|
+
return {};
|
|
39
|
+
}) as typeof childProcess.execFile;
|
|
40
|
+
syncBuiltinESMExports();
|
|
41
|
+
globalThis.fetch = async (input, init) => {
|
|
42
|
+
if (init?.method === 'DELETE') deletes++;
|
|
43
|
+
return Response.json({ value: new URL(String(input)).pathname === '/session' ? { sessionId: 'test-session' } : {} });
|
|
44
|
+
};
|
|
45
|
+
const { replayAppiumScript, launchAppOnDevice, clearAppDataOnDevice } = await import('./appium-runner');
|
|
46
|
+
const script: AppiumRecordedScriptRecord = { id: 'audit', name: 'audit', deviceId: 'fake-device', appPackage: '', appActivity: '', createdAt: '', updatedAt: '', steps: [] };
|
|
47
|
+
const run = (steps: AppiumRecordedStepRecord[]) => replayAppiumScript({ ...script, steps }, 'fake-device', undefined, controller.signal, { screenshotReport: false, reportSummaryEnabled: false });
|
|
48
|
+
const actions: AppiumRecordedStepRecord[] = [
|
|
49
|
+
{ id: 'launch', type: 'launchApp', label: 'launch', value: 'example.test' },
|
|
50
|
+
{ id: 'clear', type: 'clearAppData', label: 'clear', value: 'example.test' },
|
|
51
|
+
{ id: 'tap', type: 'coordinateTap', label: 'tap', fallback: { strategy: 'bounds', centerX: 10, centerY: 10 } },
|
|
52
|
+
{ id: 'swipe', type: 'swipe', label: 'swipe', swipe: { startX: 10, startY: 10, endX: 50, endY: 50, duration: 100 } },
|
|
53
|
+
];
|
|
54
|
+
for (const action of actions) {
|
|
55
|
+
mode = 'abort'; controller = new AbortController(); deletes = 0;
|
|
56
|
+
assert.equal((await run([action])).stopped, true, action.type);
|
|
57
|
+
assert.equal(deletes, 1, 'abort releases the known external session');
|
|
58
|
+
}
|
|
59
|
+
mode = 'foreground-abort'; controller = new AbortController(); launchedAfterAbort = false;
|
|
60
|
+
assert.equal((await run([actions[0]!])).stopped, true);
|
|
61
|
+
assert.equal(launchedAfterAbort, false, 'foreground cancellation must not launch monkey');
|
|
62
|
+
const count = requestCount;
|
|
63
|
+
assert.throws(() => launchAppOnDevice('fake', 'example.test', controller.signal));
|
|
64
|
+
assert.throws(() => clearAppDataOnDevice('fake', 'example.test', controller.signal));
|
|
65
|
+
assert.equal(requestCount, count, 'already-cancelled actions never spawn');
|
|
66
|
+
mode = 'timeout'; controller = new AbortController(); deletes = 0;
|
|
67
|
+
const failed = await run([actions[0]!]);
|
|
68
|
+
assert.equal(failed.success, false); assert.match(failed.output, /timed out/); assert.equal(deletes, 1);
|
|
69
|
+
|
|
70
|
+
// A local HTTP server models the idle lease at accelerated time, without a real device.
|
|
71
|
+
globalThis.fetch = original.fetch;
|
|
72
|
+
globalThis.setInterval = ((fn, ms, ...args) => original.setInterval(fn, ms === 15000 ? 15 : ms, ...args)) as typeof setInterval;
|
|
73
|
+
let active = false, timeoutSeconds = 0, heartbeats = 0, homeKeys = 0, deleted = 0;
|
|
74
|
+
let expiry: ReturnType<typeof setTimeout> | undefined;
|
|
75
|
+
let createdResolve: () => void = () => {};
|
|
76
|
+
const renew = () => { clearTimeout(expiry); expiry = setTimeout(() => { active = false; }, timeoutSeconds * 3); };
|
|
77
|
+
const server = createServer(async (req, res) => {
|
|
78
|
+
res.setHeader('Content-Type', 'application/json');
|
|
79
|
+
if (req.url === '/session' && req.method === 'POST') {
|
|
80
|
+
let body = ''; for await (const chunk of req) body += chunk;
|
|
81
|
+
timeoutSeconds = JSON.parse(body).capabilities.alwaysMatch['appium:newCommandTimeout'];
|
|
82
|
+
if (mode === 'creation-abort') { controller.abort(); await new Promise(resolve => setTimeout(resolve, 20)); }
|
|
83
|
+
active = true; renew(); createdResolve();
|
|
84
|
+
res.end(JSON.stringify({ value: { sessionId: 'delayed-session' } }));
|
|
85
|
+
} else if (req.method === 'DELETE') {
|
|
86
|
+
deleted++; active = false; clearTimeout(expiry); res.end('{"value":null}');
|
|
87
|
+
} else if (!active) {
|
|
88
|
+
res.statusCode = 404; res.end('{"value":{"error":"invalid session id"}}');
|
|
89
|
+
} else {
|
|
90
|
+
renew();
|
|
91
|
+
if (req.url?.endsWith('/timeouts')) heartbeats++;
|
|
92
|
+
if (req.url?.endsWith('/press_keycode')) homeKeys++;
|
|
93
|
+
res.end('{"value":{}}');
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
|
|
97
|
+
process.env.APPIUM_SERVER_URL = `http://127.0.0.1:${(server.address() as { port: number }).port}`;
|
|
98
|
+
try {
|
|
99
|
+
mode = 'long-idle'; controller = new AbortController();
|
|
100
|
+
const result = await run([{ id: 'wait', type: 'delay', label: 'idle', timeoutMs: 450 }, { id: 'home', type: 'key', label: 'Home', keyCode: 3 }]);
|
|
101
|
+
assert.equal(result.success, true, result.output);
|
|
102
|
+
assert.equal(timeoutSeconds, 60); assert.ok(heartbeats > 3); assert.equal(homeKeys, 1); assert.equal(deleted, 1);
|
|
103
|
+
const heartbeatCount = heartbeats;
|
|
104
|
+
await new Promise(resolve => setTimeout(resolve, 40));
|
|
105
|
+
assert.equal(heartbeats, heartbeatCount, 'heartbeat stops after cleanup');
|
|
106
|
+
mode = 'creation-abort'; controller = new AbortController();
|
|
107
|
+
const created = new Promise<void>(resolve => { createdResolve = resolve; });
|
|
108
|
+
assert.equal((await run([actions[0]!])).stopped, true);
|
|
109
|
+
await created;
|
|
110
|
+
assert.equal(active, true, 'server can create a session after the client cancelled');
|
|
111
|
+
await new Promise(resolve => setTimeout(resolve, 220));
|
|
112
|
+
assert.equal(active, false, 'unclaimed external session has a finite idle lease');
|
|
113
|
+
} finally {
|
|
114
|
+
clearTimeout(expiry); server.closeAllConnections(); await new Promise<void>(resolve => server.close(() => resolve()));
|
|
115
|
+
}
|
|
116
|
+
} finally {
|
|
117
|
+
childProcess.execFile = original.execFile; globalThis.fetch = original.fetch; globalThis.setInterval = original.setInterval; syncBuiltinESMExports();
|
|
118
|
+
for (const key of ['ANDROID_MIDSCENE_DATA_ROOT', 'ANDROID_HOME', 'ANDROID_SDK_ROOT', 'APPIUM_SERVER_URL']) {
|
|
119
|
+
if (env[key] === undefined) delete process.env[key]; else process.env[key] = env[key];
|
|
120
|
+
}
|
|
121
|
+
await rm(root, { recursive: true, force: true });
|
|
122
|
+
}
|
|
123
|
+
});
|
|
@@ -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';
|
|
@@ -23,7 +24,7 @@ import {
|
|
|
23
24
|
type AppiumRecordedStepRecord,
|
|
24
25
|
} from './repository';
|
|
25
26
|
import { clearAppDataOnDevice, launchAppOnDevice, replayAppiumScript } from './appium-runner';
|
|
26
|
-
import { isRemoteDeviceId, sendRemoteCommand } from '../remote-agents/registry';
|
|
27
|
+
import { isRemoteDeviceId, sendRemoteCommand, stopRemoteReplay } from '../remote-agents/registry';
|
|
27
28
|
import { getAdbCommand } from '../android-sdk';
|
|
28
29
|
import { createAppiumScriptExport } from './script-export';
|
|
29
30
|
import { resolveReportSummary } from '../../src/appium-recorder/report-summary';
|
|
@@ -43,21 +44,10 @@ 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) => {
|
|
60
|
-
execFile(command, args, { maxBuffer: 20 * 1024 * 1024 }, (error, stdout, stderr) => {
|
|
50
|
+
execFile(command, args, { maxBuffer: 20 * 1024 * 1024, timeout: 10000 }, (error, stdout, stderr) => {
|
|
61
51
|
if (error) {
|
|
62
52
|
reject(new Error(stderr || error.message));
|
|
63
53
|
return;
|
|
@@ -386,6 +376,10 @@ export async function handleAppiumRecorderRequest(
|
|
|
386
376
|
const parsed = await readBody<{ deviceId?: string }>(req);
|
|
387
377
|
const deviceId = parsed.deviceId || selectedDeviceId;
|
|
388
378
|
assertDeviceAllowed(deviceId);
|
|
379
|
+
if (isRemoteDeviceId(deviceId)) {
|
|
380
|
+
sendJson(res, { success: true, stopped: stopRemoteReplay(deviceId) });
|
|
381
|
+
return true;
|
|
382
|
+
}
|
|
389
383
|
const controller = replayAbortControllers.get(deviceId);
|
|
390
384
|
controller?.abort();
|
|
391
385
|
sendJson(res, { success: true, stopped: Boolean(controller) });
|
|
@@ -444,10 +438,6 @@ export async function handleAppiumRecorderRequest(
|
|
|
444
438
|
globalVariables: getPresetVariables(),
|
|
445
439
|
linkedScripts: linkedScriptSnapshot(script),
|
|
446
440
|
}) as Awaited<ReturnType<typeof replayAppiumScript>>;
|
|
447
|
-
if (result.history) {
|
|
448
|
-
try { saveRunHistory({ ...result.history, scriptId: script.id, deviceId }); }
|
|
449
|
-
catch { result.output += '\n历史记录保存失败'; }
|
|
450
|
-
}
|
|
451
441
|
const { history: _history, ...response } = result;
|
|
452
442
|
if (streamOutput) {
|
|
453
443
|
result.output?.split(/\r?\n/).forEach((line) => sendStreamEvent(res, { type: 'log', line }));
|
|
@@ -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
|
+
}
|