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/server/http-api.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { getTestAnalytics } from './analytics';
|
|
2
|
+
import { readBody, readRawBody, requestErrorStatus } from './request-body';
|
|
3
|
+
import { canManageConfig, publicConfig, resolveSavedModelKey } from './config-access';
|
|
1
4
|
import { resolveReportSummary } from '../src/appium-recorder/report-summary';
|
|
2
5
|
// The Midscene Admin HTTP API — extracted from vite.config.ts so the exact
|
|
3
6
|
// same middleware serves the standalone dev/preview flow AND the DSH plugin.
|
|
@@ -36,37 +39,12 @@ import {
|
|
|
36
39
|
import { listScriptRecords, type ScriptStepRecord } from './script-db';
|
|
37
40
|
import { importTestCaseFile, MAX_TEST_CASE_FILE_SIZE } from './test-case-import/service';
|
|
38
41
|
import { handleAppiumRecorderRequest } from './appium-recorder/routes';
|
|
42
|
+
import { getAppiumVersion } from './appium-recorder/managed-appium';
|
|
39
43
|
import { handleRemoteAgentRequest } from './remote-agents/routes';
|
|
40
44
|
import { isRemoteDeviceId, listRemoteAndroidDevices, sendRemoteCommand } from './remote-agents/registry';
|
|
41
45
|
import { getAdbCommand } from './android-sdk';
|
|
42
46
|
import { readAndroidDeviceInfo } from './android-device-info';
|
|
43
47
|
|
|
44
|
-
async function readBody<T>(req: IncomingMessage) {
|
|
45
|
-
let body = '';
|
|
46
|
-
req.on('data', (chunk) => {
|
|
47
|
-
body += chunk;
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
return await new Promise<T>((resolve) => {
|
|
51
|
-
req.on('end', () => {
|
|
52
|
-
resolve(JSON.parse(body || '{}') as T);
|
|
53
|
-
});
|
|
54
|
-
});
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
async function readRawBody(req: IncomingMessage) {
|
|
58
|
-
const chunks: Buffer[] = [];
|
|
59
|
-
req.on('data', (chunk) => {
|
|
60
|
-
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
return await new Promise<Buffer>((resolve) => {
|
|
64
|
-
req.on('end', () => {
|
|
65
|
-
resolve(Buffer.concat(chunks));
|
|
66
|
-
});
|
|
67
|
-
});
|
|
68
|
-
}
|
|
69
|
-
|
|
70
48
|
function execFileText(command: string, args: string[] = []) {
|
|
71
49
|
return new Promise<string>((resolve, reject) => {
|
|
72
50
|
execFile(command, args, { maxBuffer: 10 * 1024 * 1024 }, (error, stdout, stderr) => {
|
|
@@ -637,6 +615,12 @@ export function createApiMiddleware() {
|
|
|
637
615
|
const requestUrl = new URL(req.url || '/', 'http://localhost');
|
|
638
616
|
const pathname = requestUrl.pathname;
|
|
639
617
|
const allowLocalDevice = isLocalRequest(req);
|
|
618
|
+
if ((pathname === '/api/config' || pathname.startsWith('/api/config/') || pathname === '/api/test-model') && !canManageConfig(req)) {
|
|
619
|
+
res.statusCode = 403;
|
|
620
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
621
|
+
res.end(JSON.stringify({ message: '参数配置仅允许在本机页面访问' }));
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
640
624
|
let selectedDeviceId = getSelectedDeviceId(req, res);
|
|
641
625
|
if (!allowLocalDevice && selectedDeviceId && !isRemoteDeviceId(selectedDeviceId)) {
|
|
642
626
|
selectedDeviceId = '';
|
|
@@ -667,7 +651,7 @@ export function createApiMiddleware() {
|
|
|
667
651
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
668
652
|
res.end(JSON.stringify({ sessions: listDeviceSessions() }));
|
|
669
653
|
} catch (error) {
|
|
670
|
-
res.statusCode =
|
|
654
|
+
res.statusCode = requestErrorStatus(error);
|
|
671
655
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
672
656
|
res.end(JSON.stringify({ message: error instanceof Error ? error.message : '读取设备会话失败' }));
|
|
673
657
|
}
|
|
@@ -679,7 +663,7 @@ export function createApiMiddleware() {
|
|
|
679
663
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
680
664
|
res.end(JSON.stringify({ locks: listActiveDeviceLocks() }));
|
|
681
665
|
} catch (error) {
|
|
682
|
-
res.statusCode =
|
|
666
|
+
res.statusCode = requestErrorStatus(error);
|
|
683
667
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
684
668
|
res.end(JSON.stringify({ message: error instanceof Error ? error.message : '读取设备锁失败' }));
|
|
685
669
|
}
|
|
@@ -704,16 +688,37 @@ export function createApiMiddleware() {
|
|
|
704
688
|
const limit = Number(requestUrl.searchParams.get('limit') || 30);
|
|
705
689
|
res.end(JSON.stringify({ operations: listOperations(limit) }));
|
|
706
690
|
} catch (error) {
|
|
707
|
-
res.statusCode =
|
|
691
|
+
res.statusCode = requestErrorStatus(error);
|
|
708
692
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
709
693
|
res.end(JSON.stringify({ message: error instanceof Error ? error.message : '读取执行记录失败' }));
|
|
710
694
|
}
|
|
711
695
|
return;
|
|
712
696
|
}
|
|
713
697
|
|
|
698
|
+
if (pathname === '/api/analytics' && req.method === 'GET') {
|
|
699
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
700
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
701
|
+
try {
|
|
702
|
+
const days = Number(requestUrl.searchParams.get('days') || 0);
|
|
703
|
+
if (![0, 7, 30, 90].includes(days)) { res.statusCode = 400; res.end(JSON.stringify({ message: '请选择有效的统计范围' })); return; }
|
|
704
|
+
res.end(JSON.stringify(getTestAnalytics(days)));
|
|
705
|
+
} catch (error) {
|
|
706
|
+
res.statusCode = 500;
|
|
707
|
+
res.end(JSON.stringify({ message: error instanceof Error ? error.message : '统计数据加载失败' }));
|
|
708
|
+
}
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
if (pathname === '/api/appium-version' && req.method === 'GET') {
|
|
713
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
714
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
715
|
+
res.end(JSON.stringify(await getAppiumVersion()));
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
|
|
714
719
|
if (req.url === '/api/config' && req.method === 'GET') {
|
|
715
720
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
716
|
-
res.end(JSON.stringify(loadConfig()));
|
|
721
|
+
res.end(JSON.stringify(publicConfig(loadConfig())));
|
|
717
722
|
return;
|
|
718
723
|
}
|
|
719
724
|
|
|
@@ -732,7 +737,7 @@ export function createApiMiddleware() {
|
|
|
732
737
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
733
738
|
res.end(JSON.stringify({ success: true }));
|
|
734
739
|
} catch (error) {
|
|
735
|
-
res.statusCode = error instanceof ConfigValidationError ? 400 : 500;
|
|
740
|
+
res.statusCode = requestErrorStatus(error, error instanceof ConfigValidationError ? 400 : 500);
|
|
736
741
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
737
742
|
res.end(JSON.stringify({ message: error instanceof Error ? error.message : 'Unknown error' }));
|
|
738
743
|
}
|
|
@@ -756,7 +761,7 @@ export function createApiMiddleware() {
|
|
|
756
761
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
757
762
|
res.end(JSON.stringify({ devices, currentDeviceId: selectedDeviceId }));
|
|
758
763
|
} catch (error) {
|
|
759
|
-
res.statusCode =
|
|
764
|
+
res.statusCode = requestErrorStatus(error);
|
|
760
765
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
761
766
|
res.end(JSON.stringify({ message: error instanceof Error ? error.message : 'Unknown error' }));
|
|
762
767
|
}
|
|
@@ -781,7 +786,7 @@ export function createApiMiddleware() {
|
|
|
781
786
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
782
787
|
res.end(JSON.stringify({ success: true, currentDeviceId: selectedDeviceId }));
|
|
783
788
|
} catch (error) {
|
|
784
|
-
res.statusCode =
|
|
789
|
+
res.statusCode = requestErrorStatus(error);
|
|
785
790
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
786
791
|
res.end(JSON.stringify({ message: error instanceof Error ? error.message : 'Unknown error' }));
|
|
787
792
|
}
|
|
@@ -817,7 +822,7 @@ export function createApiMiddleware() {
|
|
|
817
822
|
res.end(stdout);
|
|
818
823
|
});
|
|
819
824
|
} catch (error) {
|
|
820
|
-
res.statusCode =
|
|
825
|
+
res.statusCode = requestErrorStatus(error);
|
|
821
826
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
822
827
|
res.end(JSON.stringify({ message: error instanceof Error ? error.message : 'Unknown error' }));
|
|
823
828
|
}
|
|
@@ -847,7 +852,7 @@ export function createApiMiddleware() {
|
|
|
847
852
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
848
853
|
res.end(JSON.stringify({ success: true, ...result }));
|
|
849
854
|
} catch (error) {
|
|
850
|
-
res.statusCode =
|
|
855
|
+
res.statusCode = requestErrorStatus(error);
|
|
851
856
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
852
857
|
res.end(JSON.stringify({ message: error instanceof Error ? error.message : 'Unknown error' }));
|
|
853
858
|
}
|
|
@@ -876,7 +881,7 @@ export function createApiMiddleware() {
|
|
|
876
881
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
877
882
|
res.end(JSON.stringify({ success: true, ...result }));
|
|
878
883
|
} catch (error) {
|
|
879
|
-
res.statusCode =
|
|
884
|
+
res.statusCode = requestErrorStatus(error);
|
|
880
885
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
881
886
|
res.end(JSON.stringify({ message: error instanceof Error ? error.message : 'Unknown error' }));
|
|
882
887
|
}
|
|
@@ -894,7 +899,7 @@ export function createApiMiddleware() {
|
|
|
894
899
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
895
900
|
res.end(JSON.stringify({ deviceId, details }));
|
|
896
901
|
} catch (error) {
|
|
897
|
-
res.statusCode =
|
|
902
|
+
res.statusCode = requestErrorStatus(error);
|
|
898
903
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
899
904
|
res.end(JSON.stringify({ message: error instanceof Error ? error.message : '读取设备信息失败' }));
|
|
900
905
|
}
|
|
@@ -918,7 +923,7 @@ export function createApiMiddleware() {
|
|
|
918
923
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
919
924
|
res.end(JSON.stringify({ deviceId, ...displayInfo }));
|
|
920
925
|
} catch (error) {
|
|
921
|
-
res.statusCode =
|
|
926
|
+
res.statusCode = requestErrorStatus(error);
|
|
922
927
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
923
928
|
res.end(JSON.stringify({ message: error instanceof Error ? error.message : 'Unknown error' }));
|
|
924
929
|
}
|
|
@@ -961,7 +966,7 @@ export function createApiMiddleware() {
|
|
|
961
966
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
962
967
|
res.end(JSON.stringify({ success: true, ...result }));
|
|
963
968
|
} catch (error) {
|
|
964
|
-
res.statusCode =
|
|
969
|
+
res.statusCode = requestErrorStatus(error);
|
|
965
970
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
966
971
|
res.end(JSON.stringify({ message: error instanceof Error ? error.message : 'Unknown error' }));
|
|
967
972
|
}
|
|
@@ -1042,7 +1047,7 @@ export function createApiMiddleware() {
|
|
|
1042
1047
|
skippedReason: result.skippedReason,
|
|
1043
1048
|
}));
|
|
1044
1049
|
} catch (error) {
|
|
1045
|
-
res.statusCode =
|
|
1050
|
+
res.statusCode = requestErrorStatus(error);
|
|
1046
1051
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
1047
1052
|
res.end(JSON.stringify({ message: error instanceof Error ? error.message : '启动 Android Playground 失败' }));
|
|
1048
1053
|
}
|
|
@@ -1088,7 +1093,7 @@ export function createApiMiddleware() {
|
|
|
1088
1093
|
skippedReason: result.skippedReason,
|
|
1089
1094
|
}));
|
|
1090
1095
|
} catch (error) {
|
|
1091
|
-
res.statusCode =
|
|
1096
|
+
res.statusCode = requestErrorStatus(error);
|
|
1092
1097
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
1093
1098
|
res.end(JSON.stringify({ message: error instanceof Error ? error.message : '重启 Android Playground 失败' }));
|
|
1094
1099
|
}
|
|
@@ -1108,7 +1113,11 @@ export function createApiMiddleware() {
|
|
|
1108
1113
|
family?: string;
|
|
1109
1114
|
};
|
|
1110
1115
|
}>(req);
|
|
1111
|
-
const model = parsed.model
|
|
1116
|
+
const model = { ...parsed.model };
|
|
1117
|
+
const stored = loadConfig();
|
|
1118
|
+
const savedModel = parsed.modelKey === 'promptOptimizer' ? stored.appium.promptOptimizer?.model
|
|
1119
|
+
: parsed.modelKey && ['midscene', 'scriptOptimizer', 'appium'].includes(parsed.modelKey) ? stored[parsed.modelKey as 'midscene' | 'scriptOptimizer' | 'appium'].model : undefined;
|
|
1120
|
+
model.apiKey = resolveSavedModelKey(model, savedModel);
|
|
1112
1121
|
|
|
1113
1122
|
if (parsed.save && !['midscene', 'scriptOptimizer', 'appium', 'promptOptimizer'].includes(parsed.modelKey || '')) {
|
|
1114
1123
|
throw new ConfigValidationError('请选择有效的模型配置');
|
|
@@ -1136,7 +1145,7 @@ export function createApiMiddleware() {
|
|
|
1136
1145
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
1137
1146
|
res.end(JSON.stringify({ ...result, saved: parsed.save === true }));
|
|
1138
1147
|
} catch (error) {
|
|
1139
|
-
res.statusCode =
|
|
1148
|
+
res.statusCode = requestErrorStatus(error);
|
|
1140
1149
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
1141
1150
|
res.end(JSON.stringify({ message: error instanceof Error ? error.message : 'Unknown error' }));
|
|
1142
1151
|
}
|
|
@@ -1148,7 +1157,7 @@ export function createApiMiddleware() {
|
|
|
1148
1157
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
1149
1158
|
res.end(JSON.stringify({ apps: listAppPresetRecords() }));
|
|
1150
1159
|
} catch (error) {
|
|
1151
|
-
res.statusCode =
|
|
1160
|
+
res.statusCode = requestErrorStatus(error);
|
|
1152
1161
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
1153
1162
|
res.end(JSON.stringify({ message: error instanceof Error ? error.message : 'Unknown error' }));
|
|
1154
1163
|
}
|
|
@@ -1166,7 +1175,7 @@ export function createApiMiddleware() {
|
|
|
1166
1175
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
1167
1176
|
res.end(JSON.stringify({ app }));
|
|
1168
1177
|
} catch (error) {
|
|
1169
|
-
res.statusCode = 400;
|
|
1178
|
+
res.statusCode = requestErrorStatus(error, 400);
|
|
1170
1179
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
1171
1180
|
res.end(JSON.stringify({ message: error instanceof Error ? error.message : '保存 App 配置失败' }));
|
|
1172
1181
|
}
|
|
@@ -1180,7 +1189,7 @@ export function createApiMiddleware() {
|
|
|
1180
1189
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
1181
1190
|
res.end(JSON.stringify(removeAppPresetRecord(parsed.id)));
|
|
1182
1191
|
} catch (error) {
|
|
1183
|
-
res.statusCode = 400;
|
|
1192
|
+
res.statusCode = requestErrorStatus(error, 400);
|
|
1184
1193
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
1185
1194
|
res.end(JSON.stringify({ message: error instanceof Error ? error.message : '删除 App 配置失败' }));
|
|
1186
1195
|
}
|
|
@@ -1196,7 +1205,7 @@ export function createApiMiddleware() {
|
|
|
1196
1205
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
1197
1206
|
res.end(JSON.stringify(result));
|
|
1198
1207
|
} catch (error) {
|
|
1199
|
-
res.statusCode =
|
|
1208
|
+
res.statusCode = requestErrorStatus(error);
|
|
1200
1209
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
1201
1210
|
res.end(JSON.stringify({ message: error instanceof Error ? error.message : 'Unknown error' }));
|
|
1202
1211
|
}
|
|
@@ -1210,12 +1219,12 @@ export function createApiMiddleware() {
|
|
|
1210
1219
|
throw new Error('文件大小不能超过 10MB');
|
|
1211
1220
|
}
|
|
1212
1221
|
const fileName = requestUrl.searchParams.get('fileName') || '';
|
|
1213
|
-
const buffer = await readRawBody(req);
|
|
1222
|
+
const buffer = await readRawBody(req, MAX_TEST_CASE_FILE_SIZE);
|
|
1214
1223
|
const result = await importTestCaseFile({ fileName, buffer });
|
|
1215
1224
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
1216
1225
|
res.end(JSON.stringify(result));
|
|
1217
1226
|
} catch (error) {
|
|
1218
|
-
res.statusCode = 400;
|
|
1227
|
+
res.statusCode = requestErrorStatus(error, 400);
|
|
1219
1228
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
1220
1229
|
res.end(JSON.stringify({ message: error instanceof Error ? error.message : '测试用例文件解析失败' }));
|
|
1221
1230
|
}
|
|
@@ -1229,7 +1238,7 @@ export function createApiMiddleware() {
|
|
|
1229
1238
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
1230
1239
|
res.end(JSON.stringify({ valid: true }));
|
|
1231
1240
|
} catch (error) {
|
|
1232
|
-
res.statusCode = 400;
|
|
1241
|
+
res.statusCode = requestErrorStatus(error, 400);
|
|
1233
1242
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
1234
1243
|
res.end(JSON.stringify({ message: error instanceof Error ? error.message : '代码格式错误' }));
|
|
1235
1244
|
}
|
|
@@ -1241,7 +1250,7 @@ export function createApiMiddleware() {
|
|
|
1241
1250
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
1242
1251
|
res.end(JSON.stringify({ scripts: listScriptRecords() }));
|
|
1243
1252
|
} catch (error) {
|
|
1244
|
-
res.statusCode =
|
|
1253
|
+
res.statusCode = requestErrorStatus(error);
|
|
1245
1254
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
1246
1255
|
res.end(JSON.stringify({ message: error instanceof Error ? error.message : 'Unknown error' }));
|
|
1247
1256
|
}
|
|
@@ -1257,7 +1266,7 @@ export function createApiMiddleware() {
|
|
|
1257
1266
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
1258
1267
|
res.end(JSON.stringify(deleteGeneratedScript({ id: parsed.id })));
|
|
1259
1268
|
} catch (error) {
|
|
1260
|
-
res.statusCode =
|
|
1269
|
+
res.statusCode = requestErrorStatus(error);
|
|
1261
1270
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
1262
1271
|
res.end(JSON.stringify({ message: error instanceof Error ? error.message : 'Unknown error' }));
|
|
1263
1272
|
}
|
|
@@ -1277,7 +1286,7 @@ export function createApiMiddleware() {
|
|
|
1277
1286
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
1278
1287
|
res.end(JSON.stringify(result));
|
|
1279
1288
|
} catch (error) {
|
|
1280
|
-
res.statusCode = 400;
|
|
1289
|
+
res.statusCode = requestErrorStatus(error, 400);
|
|
1281
1290
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
1282
1291
|
res.end(JSON.stringify({ message: error instanceof Error ? error.message : '保存代码失败' }));
|
|
1283
1292
|
}
|
|
@@ -1474,7 +1483,7 @@ export function createApiMiddleware() {
|
|
|
1474
1483
|
return;
|
|
1475
1484
|
}
|
|
1476
1485
|
} catch (error) {
|
|
1477
|
-
res.statusCode =
|
|
1486
|
+
res.statusCode = requestErrorStatus(error);
|
|
1478
1487
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
1479
1488
|
res.end(JSON.stringify({ message: error instanceof Error ? error.message : 'Unknown error' }));
|
|
1480
1489
|
}
|
|
@@ -216,3 +216,11 @@ export function listOperationEventRecords(operationId: string) {
|
|
|
216
216
|
ORDER BY seq ASC;
|
|
217
217
|
`).map(rowToEvent);
|
|
218
218
|
}
|
|
219
|
+
|
|
220
|
+
// Analytics only reads metadata, never model prompts, outputs or credentials.
|
|
221
|
+
export function listOperationAnalytics() {
|
|
222
|
+
initOperationRepository();
|
|
223
|
+
return querySql<Pick<OperationRow, 'script_name' | 'started_at' | 'finished_at' | 'status'>>(`
|
|
224
|
+
SELECT script_name, started_at, finished_at, status FROM operations WHERE kind = 'script_run';
|
|
225
|
+
`);
|
|
226
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readBody, requestErrorStatus } from '../request-body';
|
|
1
2
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
3
|
import { completeRemoteCommand, listRemoteAndroidDevices, pollRemoteCommand, registerRemoteAgent } from './registry';
|
|
3
4
|
import type { RemoteAgentDevice } from './protocol';
|
|
@@ -8,17 +9,6 @@ function sendJson(res: ServerResponse, payload: unknown, statusCode = 200) {
|
|
|
8
9
|
res.end(JSON.stringify(payload));
|
|
9
10
|
}
|
|
10
11
|
|
|
11
|
-
async function readBody<T>(req: IncomingMessage) {
|
|
12
|
-
let body = '';
|
|
13
|
-
req.on('data', (chunk) => {
|
|
14
|
-
body += chunk;
|
|
15
|
-
});
|
|
16
|
-
return await new Promise<T>((resolve) => {
|
|
17
|
-
req.on('end', () => {
|
|
18
|
-
resolve(JSON.parse(body || '{}') as T);
|
|
19
|
-
});
|
|
20
|
-
});
|
|
21
|
-
}
|
|
22
12
|
|
|
23
13
|
export async function handleRemoteAgentRequest(req: IncomingMessage, res: ServerResponse) {
|
|
24
14
|
const requestUrl = new URL(req.url || '/', 'http://localhost');
|
|
@@ -83,7 +73,7 @@ export async function handleRemoteAgentRequest(req: IncomingMessage, res: Server
|
|
|
83
73
|
sendJson(res, { message: 'Remote Agent 接口不存在' }, 404);
|
|
84
74
|
return true;
|
|
85
75
|
} catch (error) {
|
|
86
|
-
sendJson(res, { message: error instanceof Error ? error.message : 'Remote Agent 请求失败' },
|
|
76
|
+
sendJson(res, { message: error instanceof Error ? error.message : 'Remote Agent 请求失败' }, requestErrorStatus(error));
|
|
87
77
|
return true;
|
|
88
78
|
}
|
|
89
79
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { createServer, type IncomingMessage } from 'node:http';
|
|
4
|
+
import { PassThrough } from 'node:stream';
|
|
5
|
+
import { readBody, readRawBody, requestErrorStatus } from './request-body';
|
|
6
|
+
|
|
7
|
+
test('malformed JSON returns 400 and subsequent requests remain healthy', async () => {
|
|
8
|
+
const server = createServer(async (req, res) => {
|
|
9
|
+
try { res.end(JSON.stringify(await readBody(req))); }
|
|
10
|
+
catch (error) { res.statusCode = requestErrorStatus(error); res.end(); }
|
|
11
|
+
});
|
|
12
|
+
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
|
|
13
|
+
const address = server.address();
|
|
14
|
+
assert.ok(address && typeof address === 'object');
|
|
15
|
+
try {
|
|
16
|
+
const url = `http://127.0.0.1:${address.port}`;
|
|
17
|
+
for (const body of ['{', 'null', '[]', '123']) assert.equal((await fetch(url, { method: 'POST', body })).status, 400);
|
|
18
|
+
const response = await fetch(url, { method: 'POST', body: '{"ok":true}' });
|
|
19
|
+
assert.equal(response.status, 200);
|
|
20
|
+
assert.deepEqual(await response.json(), { ok: true });
|
|
21
|
+
} finally { server.closeAllConnections(); await new Promise<void>(resolve => server.close(() => resolve())); }
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('body limits cover content-length and streamed chunks; aborts reject without crashing', async () => {
|
|
25
|
+
for (const useLength of [true, false]) {
|
|
26
|
+
const stream = new PassThrough();
|
|
27
|
+
const req = Object.assign(stream, { headers: useLength ? { 'content-length': '9' } : {} }) as unknown as IncomingMessage;
|
|
28
|
+
const promise = readRawBody(req, 8);
|
|
29
|
+
const rejected = assert.rejects(promise, error => requestErrorStatus(error) === 413);
|
|
30
|
+
if (!useLength) { stream.write('1234'); stream.write('56789'); }
|
|
31
|
+
await rejected;
|
|
32
|
+
stream.end();
|
|
33
|
+
}
|
|
34
|
+
const stream = Object.assign(new PassThrough(), { headers: {} });
|
|
35
|
+
const rejected = assert.rejects(readRawBody(stream as unknown as IncomingMessage), error => requestErrorStatus(error) === 400);
|
|
36
|
+
stream.emit('aborted');
|
|
37
|
+
stream.emit('error', new Error('ECONNRESET'));
|
|
38
|
+
await rejected;
|
|
39
|
+
stream.destroy();
|
|
40
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { IncomingMessage } from 'node:http';
|
|
2
|
+
|
|
3
|
+
export class RequestBodyError extends Error {
|
|
4
|
+
constructor(message: string, readonly statusCode: number) { super(message); }
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function requestErrorStatus(error: unknown, fallback = 500) {
|
|
8
|
+
return error instanceof RequestBodyError ? error.statusCode : fallback;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function readRawBody(req: IncomingMessage, limit = 16 * 1024 * 1024): Promise<Buffer> {
|
|
12
|
+
return new Promise((resolve, reject) => {
|
|
13
|
+
const chunks: Buffer[] = [];
|
|
14
|
+
let size = 0;
|
|
15
|
+
const timer = setTimeout(() => fail(new RequestBodyError('请求读取超时', 408)), 30_000);
|
|
16
|
+
const cleanup = () => {
|
|
17
|
+
clearTimeout(timer);
|
|
18
|
+
req.off('data', data); req.off('end', end); req.off('aborted', aborted); req.off('error', fail);
|
|
19
|
+
};
|
|
20
|
+
const fail = (error: Error) => {
|
|
21
|
+
cleanup(); chunks.length = 0;
|
|
22
|
+
// An aborted socket can emit an error after the aborted event.
|
|
23
|
+
req.once('error', () => {});
|
|
24
|
+
req.resume(); reject(error);
|
|
25
|
+
};
|
|
26
|
+
const data = (chunk: Buffer | string) => {
|
|
27
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
28
|
+
size += buffer.length;
|
|
29
|
+
if (size > limit) { fail(new RequestBodyError('请求内容过大', 413)); return; }
|
|
30
|
+
chunks.push(buffer);
|
|
31
|
+
};
|
|
32
|
+
const end = () => { cleanup(); resolve(Buffer.concat(chunks)); };
|
|
33
|
+
const aborted = () => fail(new RequestBodyError('请求已中断', 400));
|
|
34
|
+
req.on('data', data); req.once('end', end); req.once('aborted', aborted); req.once('error', fail);
|
|
35
|
+
if (Number(req.headers['content-length']) > limit) fail(new RequestBodyError('请求内容过大', 413));
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function readBody<T>(req: IncomingMessage): Promise<T> {
|
|
40
|
+
const body = await readRawBody(req);
|
|
41
|
+
try {
|
|
42
|
+
const value: unknown = JSON.parse(body.toString('utf8') || '{}');
|
|
43
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error();
|
|
44
|
+
return value as T;
|
|
45
|
+
} catch {
|
|
46
|
+
throw new RequestBodyError('请求必须是有效的 JSON 对象', 400);
|
|
47
|
+
}
|
|
48
|
+
}
|