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.
Files changed (51) hide show
  1. package/CHANGELOG.md +15 -1
  2. package/README.md +15 -1
  3. package/package.json +1 -1
  4. package/remote-agent/index.ts +50 -22
  5. package/server/analytics.test.ts +46 -0
  6. package/server/analytics.ts +24 -0
  7. package/server/api-safety-analytics.test.ts +57 -0
  8. package/server/appium-auto-save.test.ts +6 -0
  9. package/server/appium-recorder/appium-runner.ts +30 -9
  10. package/server/appium-recorder/appium-version.test.ts +55 -0
  11. package/server/appium-recorder/managed-appium.ts +37 -1
  12. package/server/appium-recorder/managed-cleanup.test.ts +38 -8
  13. package/server/appium-recorder/replay-lifecycle.test.ts +123 -0
  14. package/server/appium-recorder/routes.ts +8 -18
  15. package/server/appium-recorder/run-history.ts +11 -0
  16. package/server/config-access.test.ts +27 -0
  17. package/server/config-access.ts +27 -0
  18. package/server/http-api.ts +63 -54
  19. package/server/operations/repository.ts +8 -0
  20. package/server/remote-agents/protocol.ts +1 -0
  21. package/server/remote-agents/registry.test.ts +65 -0
  22. package/server/remote-agents/registry.ts +60 -5
  23. package/server/remote-agents/replay-integration.test.ts +79 -0
  24. package/server/remote-agents/routes.ts +12 -15
  25. package/server/request-body.test.ts +40 -0
  26. package/server/request-body.ts +48 -0
  27. package/src/App.vue +96 -758
  28. package/src/analytics/summary.ts +75 -0
  29. package/src/api.ts +11 -0
  30. package/src/appium-recorder/AppiumPage.vue +4 -1
  31. package/src/components/AppSidebar.vue +31 -0
  32. package/src/components/analytics/AnalysisCharts.vue +189 -0
  33. package/src/components/analytics/ReportAnalysis.vue +78 -0
  34. package/src/components/config/AppiumConfigPanel.vue +4 -1
  35. package/src/components/config/AppiumModelSettings.vue +1 -1
  36. package/src/components/config/BaseConfigPanel.vue +6 -2
  37. package/src/components/config/MidsceneConfigPanel.vue +4 -5
  38. package/src/components/help/HelpCenter.vue +185 -0
  39. package/src/composables/use-device-preview.ts +279 -0
  40. package/src/composables/use-initial-resource.ts +17 -0
  41. package/src/composables/use-script-execution.ts +277 -0
  42. package/src/config/credentials.ts +2 -0
  43. package/src/config/use-appium-auto-save.ts +22 -11
  44. package/src/config/use-model-config.ts +157 -0
  45. package/src/main.ts +2 -2
  46. package/src/pages/AnalysisPage.vue +60 -0
  47. package/src/pages/ConfigPage.vue +36 -5
  48. package/src/style.css +711 -123
  49. package/src/types.ts +7 -1
  50. package/src/components/help/HelpCenterDialog.vue +0 -160
  51. 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
+ }
@@ -20,6 +20,7 @@ export type RemoteCommand = {
20
20
  type: RemoteCommandType;
21
21
  deviceId: string;
22
22
  payload?: Record<string, unknown>;
23
+ cancelled?: boolean;
23
24
  };
24
25
 
25
26
  export type RemoteCommandResult = {
@@ -0,0 +1,65 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { registerRemoteAgent, makeRemoteDeviceId, sendRemoteCommand, pollRemoteCommand, completeRemoteCommand, stopRemoteReplay } from './registry';
4
+
5
+ test('replay heartbeat renews beyond ten minutes, cancellation survives dispatch, late results save once', async () => {
6
+ const originalSetTimeout = globalThis.setTimeout;
7
+ const originalClearTimeout = globalThis.clearTimeout;
8
+ const originalNow = Date.now;
9
+ const token = process.env.REMOTE_AGENT_TOKEN;
10
+ let now = originalNow();
11
+ const timers = new Set<{ at: number; callback: () => void; refresh: () => void }>();
12
+ const advance = (ms: number) => {
13
+ now += ms;
14
+ for (const timer of [...timers]) if (timer.at <= now) { timers.delete(timer); timer.callback(); }
15
+ };
16
+ try {
17
+ Date.now = () => now;
18
+ globalThis.setTimeout = ((callback: () => void, ms: number) => {
19
+ const timer = { at: now + ms, callback, refresh: () => { timer.at = now + ms; } };
20
+ timers.add(timer); return timer;
21
+ }) as unknown as typeof setTimeout;
22
+ globalThis.clearTimeout = ((timer: any) => { timers.delete(timer); }) as typeof clearTimeout;
23
+ const agentId = 'test-lease-agent';
24
+ const heartbeat = () => registerRemoteAgent({ agentId, token, devices: [{ id: 'device', status: 'device' }] });
25
+ const device = makeRemoteDeviceId(agentId, 'device');
26
+ heartbeat();
27
+ const result = sendRemoteCommand(device, 'replay', { script: { id: 'script' } });
28
+ let completed = false;
29
+ void result.then(() => { completed = true; });
30
+ const command = pollRemoteCommand({ agentId, token })!;
31
+ assert.throws(() => sendRemoteCommand(device, 'replay'), /已有回放任务/);
32
+ for (let i = 0; i < 70; i++) { advance(10000); heartbeat(); }
33
+ assert.equal(completed, false, 'healthy replay remains pending after 11 minutes');
34
+ assert.equal(stopRemoteReplay(device), true);
35
+ assert.deepEqual(heartbeat().cancelledCommandIds, [command.id]);
36
+ assert.throws(() => completeRemoteCommand({ agentId: 'other', token, commandId: command.id, ok: true }), /不属于/);
37
+ let saves = 0;
38
+ const stopped = { stopped: true, history: { status: 'stopped' } };
39
+ completeRemoteCommand({ agentId, token, commandId: command.id, ok: true, data: stopped }, () => { saves++; });
40
+ assert.deepEqual(await result, stopped);
41
+ completeRemoteCommand({ agentId, token, commandId: command.id, ok: true, data: stopped }, () => { saves++; });
42
+ assert.equal(saves, 1, 'result retry must not duplicate history');
43
+ assert.equal(stopRemoteReplay(device), false);
44
+
45
+ const queuedResult = sendRemoteCommand(device, 'replay');
46
+ assert.equal(stopRemoteReplay(device), true);
47
+ const queued = pollRemoteCommand({ agentId, token })!;
48
+ assert.equal(queued.cancelled, true, 'cancel before poll survives delivery');
49
+ completeRemoteCommand({ agentId, token, commandId: queued.id, ok: true }); await queuedResult;
50
+
51
+ const lateResult = sendRemoteCommand(device, 'replay', { script: { id: 'late-script' } });
52
+ const rejected = assert.rejects(lateResult, /心跳超时/);
53
+ const late = pollRemoteCommand({ agentId, token })!;
54
+ advance(45001); await rejected;
55
+ assert.deepEqual(heartbeat().cancelledCommandIds, [late.id], 'reconnecting agent stops expired replay');
56
+ const input = { agentId, token, commandId: late.id, ok: true, data: { history: {} } };
57
+ assert.throws(() => completeRemoteCommand(input, () => { throw new Error('disk busy'); }), /disk busy/);
58
+ completeRemoteCommand(input, command => { assert.equal((command.payload?.script as { id: string }).id, 'late-script'); saves++; });
59
+ completeRemoteCommand(input, () => { saves++; });
60
+ assert.equal(saves, 2, 'late result is saved once even after a temporary persistence failure');
61
+ assert.deepEqual(heartbeat().cancelledCommandIds, []);
62
+ } finally {
63
+ globalThis.setTimeout = originalSetTimeout; globalThis.clearTimeout = originalClearTimeout; Date.now = originalNow;
64
+ }
65
+ });
@@ -3,7 +3,9 @@ import type { RemoteAgentDevice, RemoteAndroidDevice, RemoteCommand, RemoteComma
3
3
  const REMOTE_DEVICE_PREFIX = 'remote:';
4
4
  const AGENT_TTL_MS = 15_000;
5
5
  const COMMAND_TIMEOUT_MS = 15_000;
6
- const REPLAY_COMMAND_TIMEOUT_MS = 10 * 60_000;
6
+ // 回放由代理心跳续期,运行时长不受固定十分钟限制。
7
+ const REPLAY_COMMAND_TIMEOUT_MS = 45_000;
8
+ const LATE_RESULT_TTL_MS = 24 * 60 * 60_000;
7
9
 
8
10
  type RegisteredAgent = {
9
11
  id: string;
@@ -14,6 +16,9 @@ type RegisteredAgent = {
14
16
  };
15
17
 
16
18
  type PendingCommand = RemoteCommand & {
19
+ agentId: string;
20
+ delivered: boolean;
21
+ cancelRequested: boolean;
17
22
  resolve: (result: unknown) => void;
18
23
  reject: (error: Error) => void;
19
24
  timer: ReturnType<typeof setTimeout>;
@@ -22,6 +27,24 @@ type PendingCommand = RemoteCommand & {
22
27
  const agents = new Map<string, RegisteredAgent>();
23
28
  const queues = new Map<string, RemoteCommand[]>();
24
29
  const pending = new Map<string, PendingCommand>();
30
+ // ponytail: 当前进程保留离线回放身份 24 小时;跨服务重启补交需要持久化任务表。
31
+ const lateReplays = new Map<string, { command: RemoteCommand; agentId: string; expiresAt: number }>();
32
+ function pruneLateReplays() {
33
+ for (const [id, receipt] of lateReplays) if (receipt.expiresAt <= Date.now()) lateReplays.delete(id);
34
+ }
35
+
36
+ export function stopRemoteReplay(deviceId: string) {
37
+ const target = parseRemoteDeviceId(deviceId);
38
+ if (!target) return false;
39
+ let stopped = false;
40
+ for (const command of pending.values()) {
41
+ if (command.type === 'replay' && command.agentId === target.agentId && command.deviceId === target.deviceSerial) {
42
+ command.cancelRequested = true;
43
+ stopped = true;
44
+ }
45
+ }
46
+ return stopped;
47
+ }
25
48
 
26
49
  function validateToken(token?: string) {
27
50
  const expected = process.env.REMOTE_AGENT_TOKEN || '';
@@ -70,6 +93,17 @@ export function registerRemoteAgent(input: {
70
93
  devices: input.devices || [],
71
94
  lastSeenAt: Date.now(),
72
95
  });
96
+ pruneLateReplays();
97
+ const cancelledCommandIds: string[] = [];
98
+ for (const command of pending.values()) {
99
+ if (command.agentId !== agentId || command.type !== 'replay') continue;
100
+ command.timer.refresh();
101
+ if (command.cancelRequested) cancelledCommandIds.push(command.id);
102
+ }
103
+ for (const receipt of lateReplays.values()) {
104
+ if (receipt.agentId === agentId) cancelledCommandIds.push(receipt.command.id);
105
+ }
106
+ return { cancelledCommandIds };
73
107
  }
74
108
 
75
109
  export function listRemoteAndroidDevices(): RemoteAndroidDevice[] {
@@ -88,15 +122,27 @@ export function listRemoteAndroidDevices(): RemoteAndroidDevice[] {
88
122
  export function pollRemoteCommand(input: { agentId: string; token?: string }) {
89
123
  validateToken(input.token);
90
124
  const queue = queues.get(input.agentId) || [];
91
- return queue.shift() || null;
125
+ const command = queue.shift();
126
+ if (!command) return null;
127
+ const task = pending.get(command.id);
128
+ if (!task) return null;
129
+ task.delivered = true;
130
+ return { ...command, cancelled: task.cancelRequested };
92
131
  }
93
132
 
94
133
  export function completeRemoteCommand(input: {
95
134
  agentId: string;
96
135
  token?: string;
97
- } & RemoteCommandResult) {
136
+ } & RemoteCommandResult, saveResult?: (command: RemoteCommand) => void) {
98
137
  validateToken(input.token);
138
+ pruneLateReplays();
99
139
  const command = pending.get(input.commandId);
140
+ const late = lateReplays.get(input.commandId);
141
+ if (!command && !late) return;
142
+ if ((command?.agentId ?? late?.agentId) !== input.agentId) throw new Error('远程命令不属于当前代理');
143
+ // 先落库再确认;保存失败时保留任务身份,让代理重试上报。
144
+ if (input.ok) saveResult?.(command ?? late!.command);
145
+ lateReplays.delete(input.commandId);
100
146
  if (!command) return;
101
147
  pending.delete(input.commandId);
102
148
  clearTimeout(command.timer);
@@ -112,6 +158,10 @@ export function sendRemoteCommand(deviceId: string, type: RemoteCommandType, pay
112
158
  if (!parsed) throw new Error('远程设备 ID 无效');
113
159
  const agent = agents.get(parsed.agentId);
114
160
  if (!agent || !isOnline(agent)) throw new Error('远程设备代理已离线');
161
+ if (type === 'replay' && [...pending.values()].some(command =>
162
+ command.type === 'replay' && command.agentId === parsed.agentId && command.deviceId === parsed.deviceSerial)) {
163
+ throw new Error('该设备已有回放任务正在执行');
164
+ }
115
165
  const commandId = `remote_cmd_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
116
166
  const command: RemoteCommand = {
117
167
  id: commandId,
@@ -123,12 +173,17 @@ export function sendRemoteCommand(deviceId: string, type: RemoteCommandType, pay
123
173
  return new Promise<unknown>((resolve, reject) => {
124
174
  const timeoutMs = type === 'replay' ? REPLAY_COMMAND_TIMEOUT_MS : COMMAND_TIMEOUT_MS;
125
175
  const timer = setTimeout(() => {
176
+ const task = pending.get(commandId);
126
177
  pending.delete(commandId);
178
+ if (type === 'replay' && task?.delivered) {
179
+ pruneLateReplays();
180
+ lateReplays.set(commandId, { command, agentId: parsed.agentId, expiresAt: Date.now() + LATE_RESULT_TTL_MS });
181
+ }
127
182
  const queue = queues.get(parsed.agentId) || [];
128
183
  queues.set(parsed.agentId, queue.filter((item) => item.id !== commandId));
129
- reject(new Error('远程设备命令超时'));
184
+ reject(new Error(type === 'replay' ? '远程代理心跳超时,恢复连接后将终止任务并补交结果' : '远程设备命令超时'));
130
185
  }, timeoutMs);
131
- pending.set(commandId, { ...command, resolve, reject, timer });
186
+ pending.set(commandId, { ...command, agentId: parsed.agentId, delivered: false, cancelRequested: false, resolve, reject, timer });
132
187
  const queue = queues.get(parsed.agentId) || [];
133
188
  queue.push(command);
134
189
  queues.set(parsed.agentId, queue);
@@ -0,0 +1,79 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { createServer } from 'node:http';
4
+ import { spawn } from 'node:child_process';
5
+ import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
6
+ import { tmpdir } from 'node:os';
7
+ import { join, resolve } from 'node:path';
8
+
9
+ test('real agent receives stop during replay and retries result delivery without replaying', { skip: process.platform === 'win32', timeout: 25000 }, async () => {
10
+ const root = await mkdtemp(join(tmpdir(), 'remote-replay-'));
11
+ const env = { ...process.env };
12
+ process.env.ANDROID_MIDSCENE_DATA_ROOT = join(root, 'central');
13
+ process.env.REMOTE_AGENT_TOKEN = 'test-only-token';
14
+ const { handleRemoteAgentRequest } = await import('./routes');
15
+ const { handleAppiumRecorderRequest } = await import('../appium-recorder/routes');
16
+ const { listRemoteAndroidDevices } = await import('./registry');
17
+ const { saveAppiumRecordedScript } = await import('../appium-recorder/repository');
18
+ const { listRunHistory } = await import('../appium-recorder/run-history');
19
+ let sessions = 0, deletes = 0, keys = 0, resultAttempts = 0;
20
+ const server = createServer(async (req, res) => {
21
+ if (req.url === '/session') {
22
+ sessions++; res.setHeader('Content-Type', 'application/json'); res.end('{"value":{"sessionId":"test-session"}}'); return;
23
+ }
24
+ if (req.url?.startsWith('/session/')) {
25
+ if (req.method === 'DELETE') deletes++;
26
+ if (req.url.endsWith('/press_keycode')) keys++;
27
+ res.setHeader('Content-Type', 'application/json'); res.end('{"value":{}}'); return;
28
+ }
29
+ if (req.url === '/api/remote-agents/result' && ++resultAttempts === 1) {
30
+ req.resume(); res.statusCode = 503; res.end('{"message":"temporary failure"}'); return;
31
+ }
32
+ if (await handleRemoteAgentRequest(req, res)) return;
33
+ if (await handleAppiumRecorderRequest(req, res, '')) return;
34
+ res.statusCode = 404; res.end();
35
+ });
36
+ let agent: ReturnType<typeof spawn> | undefined;
37
+ const waitFor = async (check: () => boolean) => {
38
+ const until = Date.now() + 12000;
39
+ while (!check()) { assert.ok(Date.now() < until, 'agent action completed within deadline'); await new Promise(resolve => setTimeout(resolve, 25)); }
40
+ };
41
+ try {
42
+ const sdk = join(root, 'sdk'), bin = join(sdk, 'platform-tools');
43
+ await mkdir(bin, { recursive: true });
44
+ await writeFile(join(bin, 'adb'), '#!/usr/bin/env node\nif(process.argv.includes("devices")) console.log("List of devices attached\\nfake-device device model:Test");\n', { mode: 0o755 });
45
+ await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
46
+ const base = `http://127.0.0.1:${(server.address() as { port: number }).port}`;
47
+ const script = saveAppiumRecordedScript({ name: 'remote-test', appPackage: 'example.test', steps: [
48
+ { id: 'wait', type: 'delay', label: 'wait', timeoutMs: 60000 },
49
+ { id: 'home', type: 'key', label: 'Home', keyCode: 3 },
50
+ ] });
51
+ agent = spawn(process.execPath, ['--import', 'tsx', resolve('remote-agent/index.ts'), '--server', base, '--agent-id', 'integration-agent'], {
52
+ env: { ...process.env, ANDROID_MIDSCENE_DATA_ROOT: join(root, 'agent'), ANDROID_HOME: sdk, ANDROID_SDK_ROOT: sdk, APPIUM_SERVER_URL: base, PATH: `${bin}:${process.env.PATH}` },
53
+ stdio: ['ignore', 'ignore', 'pipe'],
54
+ });
55
+ let stderr = ''; agent.stderr?.on('data', chunk => { stderr += chunk; });
56
+ await waitFor(() => listRemoteAndroidDevices().length > 0);
57
+ const deviceId = listRemoteAndroidDevices()[0]!.id;
58
+ const post = (path: string, data: unknown) => fetch(base + path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), signal: AbortSignal.timeout(18000) });
59
+ const running = post(`/api/appium-recorder/scripts/${script.id}/replay`, { deviceId, reportSummaryEnabled: false }).then(response => response.json() as Promise<{ stopped: boolean }>);
60
+ await waitFor(() => sessions === 1);
61
+ const stop = await (await post('/api/appium-recorder/replay/stop', { deviceId })).json() as { stopped: boolean };
62
+ assert.equal(stop.stopped, true);
63
+ const result = await running;
64
+ assert.equal(result.stopped, true, stderr);
65
+ assert.equal(sessions, 1, 'retrying the result never replays device actions');
66
+ assert.equal(deletes, 1); assert.equal(keys, 0, 'Home must not run after cancellation');
67
+ assert.equal(resultAttempts, 2, 'temporary result failure is retried');
68
+ const history = listRunHistory(script.id);
69
+ assert.equal(history.length, 1); assert.equal(history[0]?.status, 'stopped'); assert.equal(history[0]?.deviceId, deviceId);
70
+ } finally {
71
+ if (agent && agent.exitCode === null) {
72
+ const exited = new Promise<void>(resolve => agent!.once('exit', () => resolve()));
73
+ agent.kill('SIGKILL'); await exited;
74
+ }
75
+ server.closeAllConnections(); await new Promise<void>(resolve => server.close(() => resolve()));
76
+ for (const key of ['ANDROID_MIDSCENE_DATA_ROOT', 'REMOTE_AGENT_TOKEN']) { if (env[key] === undefined) delete process.env[key]; else process.env[key] = env[key]; }
77
+ await rm(root, { recursive: true, force: true });
78
+ }
79
+ });