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.
Files changed (43) hide show
  1. package/CHANGELOG.md +9 -1
  2. package/README.md +9 -1
  3. package/package.json +1 -1
  4. package/server/analytics.test.ts +46 -0
  5. package/server/analytics.ts +24 -0
  6. package/server/api-safety-analytics.test.ts +57 -0
  7. package/server/appium-auto-save.test.ts +6 -0
  8. package/server/appium-recorder/appium-version.test.ts +55 -0
  9. package/server/appium-recorder/managed-appium.ts +37 -1
  10. package/server/appium-recorder/routes.ts +2 -12
  11. package/server/appium-recorder/run-history.ts +11 -0
  12. package/server/config-access.test.ts +27 -0
  13. package/server/config-access.ts +27 -0
  14. package/server/http-api.ts +63 -54
  15. package/server/operations/repository.ts +8 -0
  16. package/server/remote-agents/routes.ts +2 -12
  17. package/server/request-body.test.ts +40 -0
  18. package/server/request-body.ts +48 -0
  19. package/src/App.vue +96 -758
  20. package/src/analytics/summary.ts +75 -0
  21. package/src/api.ts +11 -0
  22. package/src/appium-recorder/AppiumPage.vue +4 -1
  23. package/src/components/AppSidebar.vue +31 -0
  24. package/src/components/analytics/AnalysisCharts.vue +189 -0
  25. package/src/components/analytics/ReportAnalysis.vue +78 -0
  26. package/src/components/config/AppiumConfigPanel.vue +4 -1
  27. package/src/components/config/AppiumModelSettings.vue +1 -1
  28. package/src/components/config/BaseConfigPanel.vue +6 -2
  29. package/src/components/config/MidsceneConfigPanel.vue +4 -5
  30. package/src/components/help/HelpCenter.vue +185 -0
  31. package/src/composables/use-device-preview.ts +279 -0
  32. package/src/composables/use-initial-resource.ts +17 -0
  33. package/src/composables/use-script-execution.ts +277 -0
  34. package/src/config/credentials.ts +2 -0
  35. package/src/config/use-appium-auto-save.ts +22 -11
  36. package/src/config/use-model-config.ts +157 -0
  37. package/src/main.ts +2 -2
  38. package/src/pages/AnalysisPage.vue +60 -0
  39. package/src/pages/ConfigPage.vue +36 -5
  40. package/src/style.css +711 -123
  41. package/src/types.ts +7 -1
  42. package/src/components/help/HelpCenterDialog.vue +0 -160
  43. package/src/ui-refresh.css +0 -677
@@ -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 = 500;
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 = 500;
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 = 500;
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 = 500;
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 = 500;
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 = 500;
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 = 500;
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 = 500;
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 = 500;
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 = 500;
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 = 500;
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 = 500;
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 = 500;
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 = 500;
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 = 500;
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 = 500;
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 = 500;
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 = 500;
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 = 500;
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 请求失败' }, 500);
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
+ }