preflite 1.0.1

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 (138) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +144 -0
  3. package/dist/adapter-spi/artifact/index.js +1 -0
  4. package/dist/adapter-spi/command/index.js +1 -0
  5. package/dist/adapter-spi/install/index.js +1 -0
  6. package/dist/adapter-spi/lifecycle/index.js +1 -0
  7. package/dist/adapter-spi/resource/index.js +1 -0
  8. package/dist/adapter-spi/snapshot/index.js +1 -0
  9. package/dist/application/agent/AgentCommandPollLoop.js +48 -0
  10. package/dist/application/agent/AgentRuntimeService.js +27 -0
  11. package/dist/application/app-package/AppPackageApplicationService.js +97 -0
  12. package/dist/application/artifact/ArtifactApplicationService.js +13 -0
  13. package/dist/application/debug/DebugApplicationService.js +117 -0
  14. package/dist/application/health/HealthMetricsService.js +47 -0
  15. package/dist/application/lease/LeaseApplicationService.js +79 -0
  16. package/dist/application/query/ObservationQueryService.js +48 -0
  17. package/dist/application/reporter/ReporterApplicationService.js +41 -0
  18. package/dist/application/resource/ResourceOccupationReleaseService.js +49 -0
  19. package/dist/application/resource/ResourceRegistryService.js +113 -0
  20. package/dist/application/session/SessionApplicationService.js +39 -0
  21. package/dist/application/task/TaskApplicationService.js +378 -0
  22. package/dist/client/agentAppPackageClient.js +91 -0
  23. package/dist/domain/agent/AgentNode.js +12 -0
  24. package/dist/domain/agent/AgentRuntime.js +6 -0
  25. package/dist/domain/artifact/ArtifactPipeline.js +6 -0
  26. package/dist/domain/artifact/ArtifactRef.js +12 -0
  27. package/dist/domain/event/AgentEvent.js +10 -0
  28. package/dist/domain/event/Reporter.js +6 -0
  29. package/dist/domain/health/HealthMetrics.js +8 -0
  30. package/dist/domain/lease/Lease.js +28 -0
  31. package/dist/domain/lease/LeaseManager.js +6 -0
  32. package/dist/domain/repositories/index.js +1 -0
  33. package/dist/domain/resource/DeviceDetails.js +23 -0
  34. package/dist/domain/resource/DeviceResource.js +16 -0
  35. package/dist/domain/resource/ResourceRegistry.js +6 -0
  36. package/dist/domain/runtime/interfaces.js +1 -0
  37. package/dist/domain/session/BaseSession.js +16 -0
  38. package/dist/domain/session/DebugSession.js +3 -0
  39. package/dist/domain/session/ExecutionSession.js +3 -0
  40. package/dist/domain/session/SessionManager.js +8 -0
  41. package/dist/domain/task/TaskRecord.js +14 -0
  42. package/dist/domain/task/TaskSpec.js +12 -0
  43. package/dist/infrastructure/adapters/AdapterRegistry.js +10 -0
  44. package/dist/infrastructure/adapters/BridgeAdapters.js +6 -0
  45. package/dist/infrastructure/adapters/android/AndroidResourceAdapter.js +51 -0
  46. package/dist/infrastructure/adapters/deviceDetailsProbe.js +229 -0
  47. package/dist/infrastructure/adapters/harmony/HarmonyResourceAdapter.js +40 -0
  48. package/dist/infrastructure/adapters/ios/IOSResourceAdapter.js +182 -0
  49. package/dist/infrastructure/adapters/real/ShellCommandAndSnapshot.js +41 -0
  50. package/dist/infrastructure/airtest/AirtestRuntime.js +168 -0
  51. package/dist/infrastructure/app-package/AppPackageUrlCache.js +191 -0
  52. package/dist/infrastructure/app-package/appPackageDownloadDir.js +13 -0
  53. package/dist/infrastructure/bootstrap/BuildRuntimeContext.js +88 -0
  54. package/dist/infrastructure/cache/DirCapacityWatchdog.js +150 -0
  55. package/dist/infrastructure/config/agentConfigFile.js +96 -0
  56. package/dist/infrastructure/device/DeviceAppPackageOps.js +146 -0
  57. package/dist/infrastructure/ios/IOSWdaWatchdog.js +207 -0
  58. package/dist/infrastructure/live-debug/LiveDebugSessionManager.js +74 -0
  59. package/dist/infrastructure/live-debug/RuntimeLiveDebugAdapters.js +19 -0
  60. package/dist/infrastructure/midscene/DebugRuntimeImpl.js +533 -0
  61. package/dist/infrastructure/midscene/MidsceneRuntimeMock.js +22 -0
  62. package/dist/infrastructure/midscene/MidsceneRuntimeReal.js +552 -0
  63. package/dist/infrastructure/midscene/executionDumpWatcher.js +219 -0
  64. package/dist/infrastructure/midscene/videoRecorder.js +365 -0
  65. package/dist/infrastructure/midscene/zipReportDir.js +36 -0
  66. package/dist/infrastructure/persistence/InMemoryRepositories.js +94 -0
  67. package/dist/infrastructure/resilience/DeliveryIdDeduper.js +26 -0
  68. package/dist/infrastructure/system/CommandRunner.js +128 -0
  69. package/dist/infrastructure/transport/http/AgentEventHttpIngestClient.js +52 -0
  70. package/dist/infrastructure/transport/http/CallbackOutboxStore.js +106 -0
  71. package/dist/infrastructure/transport/http/PlatformCallbackClient.js +113 -0
  72. package/dist/infrastructure/transport/http/PlatformCommandPollClient.js +89 -0
  73. package/dist/infrastructure/transport/http/ResilientPlatformCallbackClient.js +117 -0
  74. package/dist/infrastructure/transport/midscenePaths.js +28 -0
  75. package/dist/infrastructure/transport/ws/ResilientWsOrHttpEventPublisher.js +29 -0
  76. package/dist/infrastructure/transport/ws/WsClient.js +182 -0
  77. package/dist/infrastructure/transport/ws/WsEventPublisher.js +36 -0
  78. package/dist/interfaces/http/HttpServer.js +227 -0
  79. package/dist/interfaces/websocket/AgentWsGateway.js +227 -0
  80. package/dist/main.js +368 -0
  81. package/dist/mcp/agentHttpClient.js +82 -0
  82. package/dist/mcp/agentRuntime.js +184 -0
  83. package/dist/mcp/cli.js +36 -0
  84. package/dist/mcp/doctor.js +124 -0
  85. package/dist/mcp/evidence.js +57 -0
  86. package/dist/mcp/exploration/index.js +129 -0
  87. package/dist/mcp/exploration/sessionManager.js +122 -0
  88. package/dist/mcp/exploration/tools-atomic.js +34 -0
  89. package/dist/mcp/exploration/tools-intelligent.js +33 -0
  90. package/dist/mcp/exploration/tools-session.js +276 -0
  91. package/dist/mcp/exploration/types.js +1 -0
  92. package/dist/mcp/flowStepEvents.js +114 -0
  93. package/dist/mcp/liveViewer.js +156 -0
  94. package/dist/mcp/reportReader.js +157 -0
  95. package/dist/mcp/runManager.js +161 -0
  96. package/dist/mcp/runSummary.js +72 -0
  97. package/dist/mcp/runtimeInstall.js +44 -0
  98. package/dist/mcp/server.js +260 -0
  99. package/dist/mcp/setup.js +185 -0
  100. package/dist/mcp/types.js +1 -0
  101. package/dist/mcp/userConfig.js +45 -0
  102. package/dist/mcp/visual-flow/codegen.js +576 -0
  103. package/dist/mcp/visual-flow/index.js +14 -0
  104. package/dist/mcp/visual-flow/types.js +5 -0
  105. package/dist/mcp/visual-flow/validate.js +617 -0
  106. package/dist/protocol-contracts/commands/envelope.js +24 -0
  107. package/dist/protocol-contracts/commands/index.js +1 -0
  108. package/dist/protocol-contracts/dto/index.js +1 -0
  109. package/dist/protocol-contracts/events/index.js +1 -0
  110. package/dist/protocol-contracts/queries/index.js +1 -0
  111. package/dist/shared-kernel/enums/index.js +70 -0
  112. package/dist/shared-kernel/errors/index.js +21 -0
  113. package/dist/shared-kernel/ids/index.js +18 -0
  114. package/dist/shared-kernel/time/index.js +3 -0
  115. package/dist/shared-kernel/value-objects/index.js +1 -0
  116. package/dist/utils/appPackageLocalPath.js +75 -0
  117. package/dist/utils/deviceResourceRouting.js +24 -0
  118. package/dist/utils/harmonyAgentDebugDevice.js +60 -0
  119. package/dist/utils/harmonyHdcDeviceId.js +134 -0
  120. package/dist/utils/iosAgentDebugDevice.js +72 -0
  121. package/dist/utils/iosMjpegCapture.js +90 -0
  122. package/dist/utils/liveDebugForegroundParse.js +71 -0
  123. package/dist/utils/midscene-device-session.js +353 -0
  124. package/dist/utils/midscene-task-cache-env.js +15 -0
  125. package/dist/utils/midsceneReportConstants.js +49 -0
  126. package/dist/utils/seedMidsceneTaskCache.js +61 -0
  127. package/dist/utils/task-runners/context/androidTaskRunnerContext.js +1 -0
  128. package/dist/utils/task-runners/context/harmonyTaskRunnerContext.js +1 -0
  129. package/dist/utils/task-runners/context/iosTaskRunnerContext.js +1 -0
  130. package/dist/utils/task-runners/runAndroidNativeAppTask.js +29 -0
  131. package/dist/utils/task-runners/runHarmonyNativeAppTask.js +36 -0
  132. package/dist/utils/task-runners/runIosNativeAppTask.js +30 -0
  133. package/dist/utils/task-runners/taskAppPackage.js +20 -0
  134. package/dist/utils/wrapper/resolveTaskRunnerImport.js +11 -0
  135. package/dist/utils/wrapper/wrapAndroidTaskScript.js +38 -0
  136. package/dist/utils/wrapper/wrapHarmonyTaskScript.js +42 -0
  137. package/dist/utils/wrapper/wrapIosTaskScript.js +30 -0
  138. package/package.json +46 -0
@@ -0,0 +1,353 @@
1
+ import { execFile, execSync } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ import { hdcListTargetsSimpleLines, hdcListTargetsVerboseStdout, looksLikeHdcTcpConnectKey, pickHarmonyDeviceIdFromList, resolveHarmonyDeviceIdFromVerboseList, resolveHdcCliExecutable, } from "./harmonyHdcDeviceId.js";
4
+ import { getHarmonyHdcBridgeScriptPath } from "./harmonyAgentDebugDevice.js";
5
+ import { AndroidAgent, AndroidDevice } from '@midscene/android';
6
+ import { HarmonyAgent, HarmonyDevice, getConnectedDevices as getHarmonyDevices } from '@midscene/harmony';
7
+ import { IOSAgent, IOSDevice } from '@midscene/ios';
8
+ import { midsceneTaskCacheFromEnv } from "./midscene-task-cache-env.js";
9
+ const execFileAsync = promisify(execFile);
10
+ const REPLAN_CYCLE_LIMIT = 40;
11
+ const WAIT_AFTER_ACTION = 2000;
12
+ const DEFAULT_AI_CONTEXT = '若出现权限、协议或系统弹窗请点击同意或允许;若出现登录页可关闭。';
13
+ const MIDSCENE_EVENT_PREFIX = '__MIDSCENE_EVENT__';
14
+ function flowStepIndexFromEnv() {
15
+ const raw = typeof process.env.MIDSCENE_FLOW_STEP_INDEX === 'string'
16
+ ? process.env.MIDSCENE_FLOW_STEP_INDEX.trim()
17
+ : '';
18
+ const n = raw ? Number.parseInt(raw, 10) : Number.NaN;
19
+ return Number.isFinite(n) && n >= 1 ? Math.floor(n) : undefined;
20
+ }
21
+ function flowStepIndexFromDumpTasks(tasks) {
22
+ for (const t of tasks) {
23
+ const q = t.flowStepIndex;
24
+ if (typeof q === 'number' && Number.isFinite(q) && q >= 1)
25
+ return Math.floor(q);
26
+ if (typeof q === 'string') {
27
+ const n = Number.parseInt(q.trim(), 10);
28
+ if (Number.isFinite(n) && n >= 1)
29
+ return n;
30
+ }
31
+ }
32
+ return undefined;
33
+ }
34
+ function emitMidsceneEvent(payload) {
35
+ try {
36
+ console.log(`${MIDSCENE_EVENT_PREFIX}${JSON.stringify(payload)}`);
37
+ }
38
+ catch {
39
+ /* ignore */
40
+ }
41
+ }
42
+ function finiteNumber(v) {
43
+ const n = typeof v === 'number' ? v : Number(v);
44
+ return Number.isFinite(n) && n >= 0 ? n : undefined;
45
+ }
46
+ /**
47
+ * Apply MIDSCENE_FLOW_STEP_INDEX from the current environment to each task in
48
+ * the Midscene execution dump, so _merged.dump.json can align screenshots with
49
+ * flow steps.
50
+ */
51
+ function applyFlowStepIndexToDumpTasks(tasks) {
52
+ const raw = typeof process.env.MIDSCENE_FLOW_STEP_INDEX === 'string' ? process.env.MIDSCENE_FLOW_STEP_INDEX.trim() : '';
53
+ const stepIndex = raw ? Number.parseInt(raw, 10) : Number.NaN;
54
+ if (!Number.isFinite(stepIndex) || stepIndex < 1)
55
+ return;
56
+ for (const t of tasks) {
57
+ if (t && typeof t === 'object' && !Object.prototype.hasOwnProperty.call(t, 'flowStepIndex')) {
58
+ ;
59
+ t.flowStepIndex = stepIndex;
60
+ }
61
+ }
62
+ }
63
+ function summarizeExecutionUsage(tasks) {
64
+ const summary = {
65
+ prompt_tokens: 0,
66
+ completion_tokens: 0,
67
+ total_tokens: 0,
68
+ cached_input: 0,
69
+ callCount: 0,
70
+ };
71
+ let durationMs = 0;
72
+ let hasDuration = false;
73
+ for (const task of tasks) {
74
+ const taskInfo = task.log?.taskInfo;
75
+ const usage = task.usage ?? taskInfo?.usage;
76
+ if (!usage)
77
+ continue;
78
+ summary.callCount += 1;
79
+ summary.prompt_tokens += finiteNumber(usage.prompt_tokens) ?? 0;
80
+ summary.completion_tokens += finiteNumber(usage.completion_tokens) ?? 0;
81
+ summary.total_tokens += finiteNumber(usage.total_tokens) ?? 0;
82
+ summary.cached_input += finiteNumber(usage.cached_input) ?? 0;
83
+ const usageDuration = finiteNumber(usage.time_cost);
84
+ const taskInfoDuration = finiteNumber(taskInfo?.durationMs);
85
+ const taskDuration = usageDuration ?? taskInfoDuration;
86
+ if (taskDuration != null) {
87
+ durationMs += taskDuration;
88
+ hasDuration = true;
89
+ }
90
+ if (typeof usage.model_name === 'string' && usage.model_name.trim()) {
91
+ summary.model_name = usage.model_name.trim();
92
+ }
93
+ if (typeof usage.model_description === 'string' && usage.model_description.trim()) {
94
+ summary.model_description = usage.model_description.trim();
95
+ }
96
+ }
97
+ if (summary.callCount <= 0)
98
+ return null;
99
+ if (summary.total_tokens <= 0) {
100
+ summary.total_tokens = summary.prompt_tokens + summary.completion_tokens;
101
+ }
102
+ if (hasDuration)
103
+ summary.durationMs = durationMs;
104
+ return summary;
105
+ }
106
+ function attachMidsceneAgentEventHooks(platform, agent) {
107
+ agent.onTaskStartTip = (tip) => {
108
+ const text = typeof tip === 'string' ? tip.trim() : '';
109
+ if (!text)
110
+ return;
111
+ emitMidsceneEvent({
112
+ type: 'task-start-tip',
113
+ platform,
114
+ tip: text,
115
+ ts: Date.now(),
116
+ });
117
+ };
118
+ if (typeof agent.addDumpUpdateListener === 'function') {
119
+ let lastName = '';
120
+ let lastMetricKey = '';
121
+ agent.addDumpUpdateListener((_dump, executionDump) => {
122
+ const name = typeof executionDump?.name === 'string' ? executionDump.name.trim() : '';
123
+ const tasks = Array.isArray(executionDump?.tasks) ? executionDump.tasks : [];
124
+ applyFlowStepIndexToDumpTasks(tasks);
125
+ const usageSummary = summarizeExecutionUsage(tasks);
126
+ const metricKey = JSON.stringify({
127
+ name,
128
+ count: tasks.length,
129
+ callCount: usageSummary?.callCount,
130
+ durationMs: usageSummary?.durationMs,
131
+ promptTokens: usageSummary?.prompt_tokens,
132
+ completionTokens: usageSummary?.completion_tokens,
133
+ totalTokens: usageSummary?.total_tokens,
134
+ });
135
+ if (name && name !== lastName) {
136
+ lastName = name;
137
+ emitMidsceneEvent({
138
+ type: 'dump-update',
139
+ platform,
140
+ name,
141
+ ts: Date.now(),
142
+ });
143
+ }
144
+ if (usageSummary && metricKey !== lastMetricKey) {
145
+ lastMetricKey = metricKey;
146
+ const flowStepIndex = flowStepIndexFromEnv() ??
147
+ flowStepIndexFromDumpTasks(tasks);
148
+ emitMidsceneEvent({
149
+ type: 'task-usage',
150
+ platform,
151
+ name,
152
+ taskCount: tasks.length,
153
+ aiCallCount: usageSummary.callCount,
154
+ durationMs: usageSummary.durationMs,
155
+ usage: {
156
+ prompt_tokens: usageSummary.prompt_tokens,
157
+ completion_tokens: usageSummary.completion_tokens,
158
+ total_tokens: usageSummary.total_tokens,
159
+ cached_input: usageSummary.cached_input,
160
+ model_name: usageSummary.model_name,
161
+ model_description: usageSummary.model_description,
162
+ },
163
+ ts: Date.now(),
164
+ ...(flowStepIndex != null ? { flowStepIndex } : {}),
165
+ });
166
+ }
167
+ });
168
+ }
169
+ }
170
+ function toValidPort(port, name) {
171
+ const parsed = typeof port === 'number' ? port : Number(port);
172
+ if (!Number.isInteger(parsed) || parsed <= 0) {
173
+ throw new Error(`${name} 非法: ${String(port)}`);
174
+ }
175
+ return parsed;
176
+ }
177
+ function parseAdbDevices(output) {
178
+ return output
179
+ .split('\n')
180
+ .map((line) => line.trim())
181
+ .filter((line) => line && !line.startsWith('List of devices attached'))
182
+ .map((line) => {
183
+ const [serial, state] = line.split(/\s+/);
184
+ return { serial, state };
185
+ })
186
+ .filter((row) => Boolean(row.serial));
187
+ }
188
+ function getRemoteDevices(adbHost, adbPort) {
189
+ const raw = execSync(`adb -H ${adbHost} -P ${adbPort} devices`, {
190
+ encoding: 'utf8',
191
+ });
192
+ return parseAdbDevices(raw);
193
+ }
194
+ function chooseAndroidSerial(devices, preferredSerial) {
195
+ if (preferredSerial) {
196
+ const target = devices.find((d) => d.serial === preferredSerial);
197
+ if (!target) {
198
+ throw new Error(`未找到设备: ${preferredSerial}`);
199
+ }
200
+ if (target.state !== 'device') {
201
+ throw new Error(`设备 ${preferredSerial} 不可用,当前状态: ${target.state}`);
202
+ }
203
+ return target.serial;
204
+ }
205
+ const firstReady = devices.find((d) => d.state === 'device');
206
+ if (!firstReady) {
207
+ throw new Error('没有可用设备(state=device)');
208
+ }
209
+ return firstReady.serial;
210
+ }
211
+ async function createAndroidSession(opts) {
212
+ const adbHost = opts.adbHost ?? '127.0.0.1';
213
+ const adbPort = toValidPort(opts.adbPort ?? '5037', 'ADB_PORT');
214
+ const preferredSerial = opts.serial;
215
+ const devices = getRemoteDevices(adbHost, adbPort);
216
+ const serial = chooseAndroidSerial(devices, preferredSerial);
217
+ const device = new AndroidDevice(serial, {
218
+ remoteAdbHost: adbHost,
219
+ remoteAdbPort: adbPort,
220
+ scrcpyConfig: {
221
+ enabled: true,
222
+ },
223
+ });
224
+ const taskCache = midsceneTaskCacheFromEnv();
225
+ const agent = new AndroidAgent(device, {
226
+ aiActContext: opts.aiActContext ?? DEFAULT_AI_CONTEXT,
227
+ reportFileName: opts.reportFileName,
228
+ replanningCycleLimit: REPLAN_CYCLE_LIMIT,
229
+ waitAfterAction: WAIT_AFTER_ACTION,
230
+ ...(opts.outputFormat ? { outputFormat: opts.outputFormat } : {}),
231
+ persistExecutionDump: true,
232
+ ...(taskCache ? { cache: taskCache } : {}),
233
+ });
234
+ attachMidsceneAgentEventHooks('android', agent);
235
+ await device.connect();
236
+ return {
237
+ platform: 'android',
238
+ device,
239
+ agent,
240
+ target: {
241
+ serial,
242
+ adbHost,
243
+ adbPort,
244
+ },
245
+ };
246
+ }
247
+ async function createIosSession(opts) {
248
+ const wdaHost = opts.wdaHost ?? 'localhost';
249
+ const wdaPort = toValidPort(opts.wdaPort ?? '8100', 'WDA_PORT');
250
+ const deviceId = opts.deviceId;
251
+ const device = new IOSDevice({
252
+ wdaHost,
253
+ wdaPort,
254
+ ...(deviceId ? { deviceId } : {}),
255
+ });
256
+ const taskCache = midsceneTaskCacheFromEnv();
257
+ const agent = new IOSAgent(device, {
258
+ aiActionContext: opts.aiActContext ?? DEFAULT_AI_CONTEXT,
259
+ reportFileName: opts.reportFileName,
260
+ replanningCycleLimit: REPLAN_CYCLE_LIMIT,
261
+ waitAfterAction: WAIT_AFTER_ACTION,
262
+ ...(opts.outputFormat ? { outputFormat: opts.outputFormat } : {}),
263
+ persistExecutionDump: true,
264
+ ...(taskCache ? { cache: taskCache } : {}),
265
+ });
266
+ attachMidsceneAgentEventHooks('ios', agent);
267
+ console.log(`[ios-target] connecting ${wdaHost}:${wdaPort}${deviceId ? ` udid=${deviceId}` : ' udid=<auto>'}`);
268
+ await device.connect();
269
+ return {
270
+ platform: 'ios',
271
+ device,
272
+ agent,
273
+ target: {
274
+ deviceId,
275
+ wdaHost,
276
+ wdaPort,
277
+ },
278
+ };
279
+ }
280
+ async function createHarmonySession(opts) {
281
+ const hdcPath = opts.hdcPath;
282
+ let deviceId = opts.deviceId;
283
+ const hasRemoteTconn = typeof opts.hdcTconnHost === 'string' &&
284
+ opts.hdcTconnHost.trim() &&
285
+ typeof opts.hdcTconnPort === 'number' &&
286
+ Number.isFinite(opts.hdcTconnPort) &&
287
+ opts.hdcTconnPort > 0;
288
+ if (hasRemoteTconn) {
289
+ const host = opts.hdcTconnHost.trim();
290
+ const port = opts.hdcTconnPort;
291
+ const server = { host, port };
292
+ const hdcBin = resolveHdcCliExecutable(hdcPath);
293
+ const remoteLines = await hdcListTargetsSimpleLines(hdcBin, 20000, server);
294
+ let resolved = pickHarmonyDeviceIdFromList(remoteLines, deviceId);
295
+ if (!resolved) {
296
+ const verbose = await hdcListTargetsVerboseStdout(hdcBin, 20000, server);
297
+ resolved = resolveHarmonyDeviceIdFromVerboseList(verbose, host, port, deviceId);
298
+ }
299
+ if (!resolved) {
300
+ throw new Error(`鸿蒙:hdc -s ${host}:${port} list targets 未解析到设备序列号。请核对:1) 本机可执行「hdc -s ${host}:${port} list targets」;2) 设备端 TCP 端口与主机「HDC 端口」一致;3) 在设备管理重新探测并选择设备。`);
301
+ }
302
+ deviceId = resolved;
303
+ process.env.HDC_S = `${host}:${port}`;
304
+ process.env.HDC_REAL = hdcBin;
305
+ }
306
+ else if (!deviceId) {
307
+ const devices = await getHarmonyDevices(hdcPath);
308
+ if (!devices.length) {
309
+ throw new Error('没有可用鸿蒙设备');
310
+ }
311
+ deviceId = devices[0].deviceId;
312
+ }
313
+ if (deviceId && looksLikeHdcTcpConnectKey(deviceId)) {
314
+ throw new Error('鸿蒙设备 ID 不能为 ip:port 连接串,请通过设备管理重新探测并选择设备(应保存 hdc 序列号),或配置远程 hdc 连接参数。');
315
+ }
316
+ const realHdc = resolveHdcCliExecutable(hdcPath);
317
+ const hdcPathForMidscene = hasRemoteTconn
318
+ ? getHarmonyHdcBridgeScriptPath()
319
+ : hdcPath
320
+ ? realHdc
321
+ : undefined;
322
+ const device = new HarmonyDevice(deviceId, {
323
+ ...(hdcPathForMidscene ? { hdcPath: hdcPathForMidscene } : {}),
324
+ });
325
+ const taskCache = midsceneTaskCacheFromEnv();
326
+ const agent = new HarmonyAgent(device, {
327
+ aiActContext: opts.aiActContext ?? DEFAULT_AI_CONTEXT,
328
+ replanningCycleLimit: REPLAN_CYCLE_LIMIT,
329
+ waitAfterAction: WAIT_AFTER_ACTION,
330
+ ...(opts.reportFileName ? { reportFileName: opts.reportFileName } : {}),
331
+ ...(opts.outputFormat ? { outputFormat: opts.outputFormat } : {}),
332
+ persistExecutionDump: true,
333
+ ...(taskCache ? { cache: taskCache } : {}),
334
+ });
335
+ attachMidsceneAgentEventHooks('harmony', agent);
336
+ await device.connect();
337
+ return {
338
+ platform: 'harmony',
339
+ device,
340
+ agent,
341
+ target: {
342
+ deviceId,
343
+ ...(hdcPathForMidscene ? { hdcPath: hdcPathForMidscene } : {}),
344
+ },
345
+ };
346
+ }
347
+ export async function createMidsceneSession(opts) {
348
+ if (opts.platform === 'android')
349
+ return createAndroidSession(opts);
350
+ if (opts.platform === 'ios')
351
+ return createIosSession(opts);
352
+ return createHarmonySession(opts);
353
+ }
@@ -0,0 +1,15 @@
1
+ const VALID_STRATEGY = new Set(['read-only', 'read-write', 'write-only']);
2
+ /**
3
+ * 从子进程环境组装 Midscene `AgentOpt.cache`。
4
+ * 仅当 `MIDSCENE_TASK_CACHE_ID` 非空时启用;策略非法时回退 `read-write`。
5
+ */
6
+ export function midsceneTaskCacheFromEnv() {
7
+ const rawId = typeof process.env.MIDSCENE_TASK_CACHE_ID === 'string' ? process.env.MIDSCENE_TASK_CACHE_ID.trim() : '';
8
+ if (!rawId)
9
+ return undefined;
10
+ const rawStrat = typeof process.env.MIDSCENE_TASK_CACHE_STRATEGY === 'string'
11
+ ? process.env.MIDSCENE_TASK_CACHE_STRATEGY.trim()
12
+ : '';
13
+ const strategy = (VALID_STRATEGY.has(rawStrat) ? rawStrat : 'read-write');
14
+ return { id: rawId, strategy };
15
+ }
@@ -0,0 +1,49 @@
1
+ import { randomBytes } from "node:crypto";
2
+ /**
3
+ * 供未注入任务上下文时的回退名(如本地调试、非任务入口调用 runner)。
4
+ * @midscene/core 会拼 `${reportFileName}.html`;不要传入已含 `.html` 的整文件名。
5
+ */
6
+ export const MIDSCENE_DEFAULT_REPORT_STEM = `report-${new Date()
7
+ .toLocaleString("zh-CN", {
8
+ year: "numeric",
9
+ month: "2-digit",
10
+ day: "2-digit",
11
+ hour: "2-digit",
12
+ minute: "2-digit",
13
+ second: "2-digit",
14
+ hour12: false,
15
+ })
16
+ .replace(/[^\d]/g, "-")
17
+ .replace(/-$/, "")}`;
18
+ const PLATFORM_PREFIX = {
19
+ ANDROID: "android",
20
+ IOS: "ios",
21
+ HARMONY: "harmony",
22
+ WEB: "web",
23
+ };
24
+ /**
25
+ * 将 taskId 转为可安全作为 Midscene 报告名一部分的 token(无路径分隔符、控制字符)。
26
+ */
27
+ export function sanitizeTaskIdForReportName(taskId) {
28
+ const s = String(taskId).trim() || "unknown";
29
+ return s
30
+ .replace(/[^a-zA-Z0-9._-]+/g, "_")
31
+ .replace(/_{2,}/g, "_")
32
+ .replace(/^[._-]+|[._-]+$/g, "")
33
+ .slice(0, 96) || "unknown";
34
+ }
35
+ function randomSuffix() {
36
+ return randomBytes(4).toString("hex");
37
+ }
38
+ /**
39
+ * 生成本次任务在 Midscene `reportFileName`(无后缀)的 stem,包含平台与 taskId 便于对账与排查。
40
+ * 子进程经 `MIDSCENE_FLOW_REPORT_STEM` 读取;由 Task 下发时在 runtimeEnv 中设置。
41
+ */
42
+ export function buildMidsceneReportStemForTask(requiredPlatform, taskId, startedAtMs = Date.now()) {
43
+ const p = PLATFORM_PREFIX[requiredPlatform] ?? "app";
44
+ return `${p}-task-${sanitizeTaskIdForReportName(taskId)}-${startedAtMs}-${randomSuffix()}`;
45
+ }
46
+ /** 兼容旧 import:模块级默认 stem(无 task 上下文)。 */
47
+ export const MIDSCENE_REPORT_FILE_STEM = MIDSCENE_DEFAULT_REPORT_STEM;
48
+ export const MIDSCENE_REPORT_FILE_NAME = `${MIDSCENE_DEFAULT_REPORT_STEM}.html`;
49
+ export const MIDSCENE_REPORT_GZIP_FILE_NAME = `${MIDSCENE_DEFAULT_REPORT_STEM}.html.gz`;
@@ -0,0 +1,61 @@
1
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import path from "node:path";
4
+ import { resolveMidsceneRunDir } from "../infrastructure/transport/midscenePaths.js";
5
+ /** 与 `@midscene/shared` 的 `midscene_run/cache` 路径约定一致,避免 Agent 包未显式依赖 `@midscene/shared`。 */
6
+ const DEFAULT_RUN_DIR = "midscene_run";
7
+ function getMidsceneRunBaseDir(env) {
8
+ let basePath = resolveMidsceneRunDir(process.cwd(), env);
9
+ if (!existsSync(basePath)) {
10
+ try {
11
+ mkdirSync(basePath, { recursive: true });
12
+ }
13
+ catch {
14
+ basePath = path.join(tmpdir(), DEFAULT_RUN_DIR);
15
+ mkdirSync(basePath, { recursive: true });
16
+ }
17
+ }
18
+ return basePath;
19
+ }
20
+ function getMidsceneCacheDir(env) {
21
+ const p = path.join(getMidsceneRunBaseDir(env), "cache");
22
+ if (!existsSync(p))
23
+ mkdirSync(p, { recursive: true });
24
+ return p;
25
+ }
26
+ /**
27
+ * 在启动 Midscene 子进程前,将平台下发的缓存 YAML 写入 `midscene_run/cache/<id>.cache.yaml`。
28
+ * 环境变量:`MIDSCENE_TASK_CACHE_ID`、`MIDSCENE_TASK_CACHE_SEED_YAML_B64`(UTF-8 文本的 base64)。
29
+ */
30
+ export function seedMidsceneTaskCacheFromRuntimeEnv(runtimeEnv) {
31
+ const id = String(runtimeEnv.MIDSCENE_TASK_CACHE_ID ?? "").trim();
32
+ const b64 = String(runtimeEnv.MIDSCENE_TASK_CACHE_SEED_YAML_B64 ?? "").trim();
33
+ if (!id || !b64)
34
+ return;
35
+ let text;
36
+ try {
37
+ text = Buffer.from(b64, "base64").toString("utf8");
38
+ }
39
+ catch {
40
+ return;
41
+ }
42
+ if (!text.trim())
43
+ return;
44
+ const dir = getMidsceneCacheDir(runtimeEnv);
45
+ const fp = path.join(dir, `${id}.cache.yaml`);
46
+ writeFileSync(fp, text, "utf8");
47
+ }
48
+ export async function readMidsceneTaskCacheFileIfExists(runtimeEnv) {
49
+ const id = String(runtimeEnv.MIDSCENE_TASK_CACHE_ID ?? "").trim();
50
+ if (!id)
51
+ return null;
52
+ const fp = path.join(getMidsceneCacheDir(runtimeEnv), `${id}.cache.yaml`);
53
+ if (!existsSync(fp))
54
+ return null;
55
+ const { readFile } = await import("node:fs/promises");
56
+ const buf = await readFile(fp);
57
+ return {
58
+ relativePath: `midscene-cache/${id}.cache.yaml`,
59
+ base64: buf.toString("base64"),
60
+ };
61
+ }
@@ -0,0 +1,29 @@
1
+ import { createMidsceneSession } from "../midscene-device-session.js";
2
+ import { MIDSCENE_DEFAULT_REPORT_STEM } from "../midsceneReportConstants.js";
3
+ import { createTaskAppPackageFromEnv } from "./taskAppPackage.js";
4
+ /**
5
+ * 青桔 Android 原生 App 任务:建连、启动乘客端,再执行用例业务回调。
6
+ */
7
+ export async function runAndroidNativeAppTask(target, runBusiness) {
8
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
9
+ const stem = process.env.MIDSCENE_FLOW_REPORT_STEM?.trim() || `android-${MIDSCENE_DEFAULT_REPORT_STEM}`;
10
+ const outputFormat = process.env.MIDSCENE_OUTPUT_FORMAT === 'html-and-external-assets' ? 'html-and-external-assets' : 'single-html';
11
+ const session = await createMidsceneSession({
12
+ platform: 'android',
13
+ adbHost: target.adbHost,
14
+ adbPort: target.adbPort,
15
+ serial: target.serial,
16
+ reportFileName: stem,
17
+ outputFormat,
18
+ });
19
+ if (session.platform !== 'android') {
20
+ throw new Error('当前任务平台不是 Android');
21
+ }
22
+ const page = session.device;
23
+ const agent = session.agent;
24
+ console.log('[android-target]', session.target.adbHost + ':' + session.target.adbPort, session.target.serial ? 'serial=' + session.target.serial : 'serial=<auto>');
25
+ const { installApp, uninstallApp } = createTaskAppPackageFromEnv();
26
+ await runBusiness({ agent, page, sleep, installApp, uninstallApp }).finally(async () => {
27
+ await page.destroy();
28
+ });
29
+ }
@@ -0,0 +1,36 @@
1
+ import { createMidsceneSession } from "../midscene-device-session.js";
2
+ import { MIDSCENE_DEFAULT_REPORT_STEM } from "../midsceneReportConstants.js";
3
+ import { createTaskAppPackageFromEnv } from "./taskAppPackage.js";
4
+ /**
5
+ * 青桔鸿蒙原生 App 任务:远程时通过 bridge 注入 `hdc -s`,再建连并执行业务回调。
6
+ */
7
+ export async function runHarmonyNativeAppTask(target, runBusiness) {
8
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
9
+ const stem = process.env.MIDSCENE_FLOW_REPORT_STEM?.trim() || `harmony-${MIDSCENE_DEFAULT_REPORT_STEM}`;
10
+ const outputFormat = process.env.MIDSCENE_OUTPUT_FORMAT === 'html-and-external-assets' ? 'html-and-external-assets' : 'single-html';
11
+ const session = await createMidsceneSession({
12
+ platform: 'harmony',
13
+ deviceId: target.deviceId,
14
+ hdcPath: target.hdcPath,
15
+ hdcTconnHost: target.hdcTconnHost,
16
+ hdcTconnPort: target.hdcTconnPort,
17
+ reportFileName: stem,
18
+ outputFormat,
19
+ });
20
+ if (session.platform !== 'harmony') {
21
+ throw new Error('当前任务平台不是鸿蒙');
22
+ }
23
+ const page = session.device;
24
+ const agent = session.agent;
25
+ const remoteTargetLog = typeof target.hdcTconnHost === 'string' &&
26
+ target.hdcTconnHost.trim() &&
27
+ Number.isFinite(target.hdcTconnPort) &&
28
+ (target.hdcTconnPort ?? 0) > 0
29
+ ? `${target.hdcTconnHost}:${target.hdcTconnPort}`
30
+ : 'local-hdc';
31
+ console.log('[harmony-target]', remoteTargetLog, 'deviceId=' + target.deviceId);
32
+ const { installApp, uninstallApp } = createTaskAppPackageFromEnv();
33
+ await runBusiness({ agent, page, sleep, installApp, uninstallApp }).finally(async () => {
34
+ await page.destroy();
35
+ });
36
+ }
@@ -0,0 +1,30 @@
1
+ import { createMidsceneSession } from "../midscene-device-session.js";
2
+ import { MIDSCENE_DEFAULT_REPORT_STEM } from "../midsceneReportConstants.js";
3
+ import { createTaskAppPackageFromEnv } from "./taskAppPackage.js";
4
+ /**
5
+ * 青桔 iOS 原生 App 任务:建连、按关联应用解析的 Bundle 启动 App,再执行用例业务回调。
6
+ * 模版逻辑集中在此文件,便于维护;wrapIosTaskScript 只负责拼接入参与生成的用例片段。
7
+ */
8
+ export async function runIosNativeAppTask(target, runBusiness) {
9
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
10
+ const stem = process.env.MIDSCENE_FLOW_REPORT_STEM?.trim() || `ios-${MIDSCENE_DEFAULT_REPORT_STEM}`;
11
+ const outputFormat = process.env.MIDSCENE_OUTPUT_FORMAT === 'html-and-external-assets' ? 'html-and-external-assets' : 'single-html';
12
+ const session = await createMidsceneSession({
13
+ platform: 'ios',
14
+ wdaHost: target.wdaHost,
15
+ wdaPort: target.wdaPort,
16
+ deviceId: target.deviceId,
17
+ reportFileName: stem,
18
+ outputFormat,
19
+ });
20
+ if (session.platform !== 'ios') {
21
+ throw new Error('当前任务平台不是 iOS');
22
+ }
23
+ const page = session.device;
24
+ const agent = session.agent;
25
+ console.log('[ios-target]', session.target.wdaHost + ':' + session.target.wdaPort, session.target.deviceId ? 'udid=' + session.target.deviceId : 'udid=<auto>');
26
+ const { installApp, uninstallApp } = createTaskAppPackageFromEnv();
27
+ await runBusiness({ agent, page, sleep, installApp, uninstallApp }).finally(async () => {
28
+ await page.destroy();
29
+ });
30
+ }
@@ -0,0 +1,20 @@
1
+ import { installAppOnAgent, uninstallAppOnAgent } from "../../client/agentAppPackageClient.js";
2
+ function requireTaskResourceId() {
3
+ const id = process.env.AGENT_RESOURCE_ID?.trim();
4
+ if (!id) {
5
+ throw new Error("installApp / uninstallApp requires AGENT_RESOURCE_ID (injected by Agent when dispatching a task — do not call from bare scripts)");
6
+ }
7
+ return id;
8
+ }
9
+ export function createTaskAppPackageFromEnv() {
10
+ return {
11
+ installApp: async (appRef) => {
12
+ const resourceId = requireTaskResourceId();
13
+ await installAppOnAgent({ resourceId, appRef });
14
+ },
15
+ uninstallApp: async (bundleId) => {
16
+ const resourceId = requireTaskResourceId();
17
+ await uninstallAppOnAgent({ resourceId, bundleId });
18
+ },
19
+ };
20
+ }
@@ -0,0 +1,11 @@
1
+ import { existsSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { pathToFileURL } from 'node:url';
4
+ export function resolveTaskRunnerImportUrl(runnerName) {
5
+ const runtimeRoot = process.env.AGENT_RUNTIME_ROOT?.trim() || process.cwd();
6
+ const distRunner = path.join(runtimeRoot, 'dist/utils/task-runners', `${runnerName}.js`);
7
+ if (existsSync(distRunner)) {
8
+ return pathToFileURL(distRunner).href;
9
+ }
10
+ return pathToFileURL(path.join(process.cwd(), 'src/utils/task-runners', `${runnerName}.ts`)).href;
11
+ }
@@ -0,0 +1,38 @@
1
+ import { resolveTaskRunnerImportUrl } from './resolveTaskRunnerImport.js';
2
+ function targetToRunnerArgs(target) {
3
+ return {
4
+ adbHost: target.adbHost,
5
+ adbPort: target.adbPort,
6
+ serial: target.serial,
7
+ };
8
+ }
9
+ /**
10
+ * Android 任务执行:生成薄层脚本,实际模版见 {@link runAndroidNativeAppTask}。
11
+ */
12
+ export function wrapAndroidTaskScript(userScript, target) {
13
+ const body = userScript.trim();
14
+ const indented = body
15
+ ? body.split(/\r?\n/).map((line) => ` ${line}`).join('\n')
16
+ : ' // (用例脚本为空)';
17
+ const t = targetToRunnerArgs(target);
18
+ const adbHostLiteral = JSON.stringify(t.adbHost);
19
+ const adbPortLiteral = JSON.stringify(t.adbPort);
20
+ const serialLiteral = typeof t.serial === 'string' && t.serial.trim()
21
+ ? JSON.stringify(t.serial.trim())
22
+ : 'undefined';
23
+ const runnerImportUrlLiteral = JSON.stringify(resolveTaskRunnerImportUrl('runAndroidNativeAppTask'));
24
+ return `import { runAndroidNativeAppTask } from ${runnerImportUrlLiteral};
25
+
26
+ Promise.resolve(
27
+ runAndroidNativeAppTask(
28
+ { adbHost: ${adbHostLiteral}, adbPort: ${adbPortLiteral}, serial: ${serialLiteral} },
29
+ async ({ agent, page, sleep, installApp, uninstallApp }) => {
30
+ ${indented}
31
+ },
32
+ ),
33
+ ).catch((e) => {
34
+ console.error(e);
35
+ process.exit(1);
36
+ });
37
+ `;
38
+ }