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
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
|
+
}
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
});
|