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,219 @@
1
+ import { mkdirSync, watch } from "node:fs";
2
+ import * as fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import sharp from "sharp";
5
+ const MAX_ASSETS_PER_FLUSH = 24;
6
+ const MAX_ASSET_BYTES_PER_FLUSH = 4 * 1024 * 1024;
7
+ function fingerprint(st) {
8
+ return `${st.mtimeMs}:${st.size}`;
9
+ }
10
+ export async function mergeExecutionDumpJsonFromDir(bundleDir) {
11
+ const names = await fs.readdir(bundleDir);
12
+ const numbered = names
13
+ .map((name) => {
14
+ const m = /^(\d+)\.execution\.json$/.exec(name);
15
+ return m ? { name, i: Number.parseInt(m[1], 10) } : null;
16
+ })
17
+ .filter((x) => x !== null)
18
+ .sort((a, b) => a.i - b.i);
19
+ let base = null;
20
+ const allExecutions = [];
21
+ for (const { name } of numbered) {
22
+ const filePath = path.join(bundleDir, name);
23
+ let raw;
24
+ try {
25
+ raw = await fs.readFile(filePath, "utf8");
26
+ }
27
+ catch {
28
+ continue;
29
+ }
30
+ let part;
31
+ try {
32
+ part = JSON.parse(raw);
33
+ }
34
+ catch {
35
+ continue;
36
+ }
37
+ if (!base) {
38
+ base = { ...part };
39
+ }
40
+ const ex = part.executions;
41
+ if (Array.isArray(ex)) {
42
+ for (const item of ex) {
43
+ allExecutions.push(item);
44
+ }
45
+ }
46
+ }
47
+ if (!base) {
48
+ return null;
49
+ }
50
+ return { ...base, executions: allExecutions };
51
+ }
52
+ export function startExecutionDumpWatcher(bundleDir, onFlush, options) {
53
+ try {
54
+ mkdirSync(bundleDir, { recursive: true });
55
+ }
56
+ catch {
57
+ /* 若仍失败,watch 会抛错由调用方感知 */
58
+ }
59
+ const debounceMs = options?.debounceMs ?? 450;
60
+ let revision = 0;
61
+ const fingerprints = new Map();
62
+ let timer = null;
63
+ let stopped = false;
64
+ let watcher = null;
65
+ let lastDumpJsonSent = "";
66
+ const imageCompression = options?.imageCompression;
67
+ const compressImage = async (buf, ext) => {
68
+ if (!imageCompression || imageCompression.quality >= 100)
69
+ return buf;
70
+ if (ext === ".gif")
71
+ return buf;
72
+ let pipeline = sharp(buf, { animated: false }).rotate();
73
+ if (imageCompression.maxWidth && imageCompression.maxWidth > 0) {
74
+ pipeline = pipeline.resize({ width: imageCompression.maxWidth, withoutEnlargement: true });
75
+ }
76
+ const quality = Math.floor(Math.max(1, Math.min(100, imageCompression.quality)));
77
+ if (ext === ".jpg" || ext === ".jpeg") {
78
+ return pipeline.jpeg({ quality, mozjpeg: true }).toBuffer();
79
+ }
80
+ if (ext === ".webp") {
81
+ return pipeline.webp({ quality }).toBuffer();
82
+ }
83
+ if (ext === ".png") {
84
+ return pipeline.png({ quality, palette: true, compressionLevel: 9 }).toBuffer();
85
+ }
86
+ return buf;
87
+ };
88
+ const schedule = () => {
89
+ if (stopped)
90
+ return;
91
+ if (timer)
92
+ clearTimeout(timer);
93
+ timer = setTimeout(() => {
94
+ timer = null;
95
+ void flush();
96
+ }, debounceMs);
97
+ };
98
+ const flush = async () => {
99
+ if (stopped)
100
+ return;
101
+ let merged = null;
102
+ try {
103
+ merged = await mergeExecutionDumpJsonFromDir(bundleDir);
104
+ }
105
+ catch {
106
+ return;
107
+ }
108
+ const reportAssetFiles = [];
109
+ let bytesBudget = MAX_ASSET_BYTES_PER_FLUSH;
110
+ const skipBySize = (size, budget) => size <= 0 || size > budget;
111
+ const walkImages = async (relBase) => {
112
+ const absBase = path.join(bundleDir, relBase);
113
+ let entries;
114
+ try {
115
+ entries = await fs.readdir(absBase, { withFileTypes: true });
116
+ }
117
+ catch {
118
+ return;
119
+ }
120
+ for (const ent of entries) {
121
+ if (reportAssetFiles.length >= MAX_ASSETS_PER_FLUSH)
122
+ return;
123
+ const rel = relBase ? `${relBase}/${ent.name}` : ent.name;
124
+ if (ent.isDirectory()) {
125
+ await walkImages(rel);
126
+ continue;
127
+ }
128
+ if (!ent.isFile())
129
+ continue;
130
+ const ext = path.extname(ent.name).toLowerCase();
131
+ if (![".png", ".jpg", ".jpeg", ".webp", ".gif"].includes(ext))
132
+ continue;
133
+ const full = path.join(bundleDir, rel);
134
+ let st;
135
+ try {
136
+ st = await fs.stat(full);
137
+ }
138
+ catch {
139
+ continue;
140
+ }
141
+ const fp = fingerprint(st);
142
+ if (fingerprints.get(rel) === fp)
143
+ continue;
144
+ if (skipBySize(st.size, bytesBudget))
145
+ continue;
146
+ let buf;
147
+ try {
148
+ buf = await fs.readFile(full);
149
+ }
150
+ catch {
151
+ continue;
152
+ }
153
+ try {
154
+ const compressed = await compressImage(buf, ext);
155
+ if (compressed.length > 0 && compressed.length < buf.length) {
156
+ buf = compressed;
157
+ if (imageCompression?.overwriteFiles) {
158
+ await fs.writeFile(full, buf);
159
+ st = await fs.stat(full);
160
+ }
161
+ }
162
+ }
163
+ catch {
164
+ /* 压缩失败时保留原图 */
165
+ }
166
+ bytesBudget -= buf.length;
167
+ fingerprints.set(rel, fingerprint(st));
168
+ reportAssetFiles.push({
169
+ relativePath: rel.replace(/\\/g, "/"),
170
+ base64: buf.toString("base64"),
171
+ });
172
+ }
173
+ };
174
+ try {
175
+ await walkImages("screenshots");
176
+ }
177
+ catch {
178
+ /* screenshots 可能尚未创建 */
179
+ }
180
+ const jsonStr = merged === null ? "" : JSON.stringify(merged);
181
+ if (reportAssetFiles.length === 0) {
182
+ if (merged === null)
183
+ return;
184
+ if (jsonStr === lastDumpJsonSent)
185
+ return;
186
+ }
187
+ revision += 1;
188
+ if (jsonStr) {
189
+ lastDumpJsonSent = jsonStr;
190
+ }
191
+ try {
192
+ await onFlush({
193
+ executionDumpJson: jsonStr || "{}",
194
+ executionDumpRevision: revision,
195
+ reportAssetFiles,
196
+ });
197
+ }
198
+ catch {
199
+ /* ignore */
200
+ }
201
+ };
202
+ try {
203
+ watcher = watch(bundleDir, { persistent: false, recursive: true }, schedule);
204
+ }
205
+ catch {
206
+ watcher = watch(bundleDir, { persistent: false }, schedule);
207
+ }
208
+ schedule();
209
+ const stop = () => {
210
+ stopped = true;
211
+ if (timer) {
212
+ clearTimeout(timer);
213
+ timer = null;
214
+ }
215
+ watcher?.close();
216
+ watcher = null;
217
+ };
218
+ return { stop };
219
+ }
@@ -0,0 +1,365 @@
1
+ import { spawn } from "node:child_process";
2
+ import { mkdir, rm, stat, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+ import { augmentPathForShellCommands } from "../system/CommandRunner.js";
6
+ import { PlatformType } from "../../shared-kernel/enums/index.js";
7
+ function truthy(raw) {
8
+ const v = raw?.trim().toLowerCase();
9
+ return v === "1" || v === "true" || v === "yes" || v === "on";
10
+ }
11
+ function envString(env, key, fallback) {
12
+ const v = env[key]?.trim();
13
+ return v ? v : fallback;
14
+ }
15
+ function envNumber(env, key, fallback, min, max) {
16
+ const n = Number(env[key]);
17
+ if (!Number.isFinite(n))
18
+ return fallback;
19
+ return Math.min(max, Math.max(min, n));
20
+ }
21
+ function extractDeviceToken(resourceId) {
22
+ const idx = resourceId.indexOf(":");
23
+ return idx >= 0 ? resourceId.slice(idx + 1) : resourceId;
24
+ }
25
+ function safeNamePart(raw) {
26
+ return raw
27
+ .trim()
28
+ .replace(/[^a-zA-Z0-9._-]+/g, "-")
29
+ .replace(/^-+|-+$/g, "")
30
+ .slice(0, 80);
31
+ }
32
+ function buildOutputName(ctx) {
33
+ const scope = safeNamePart(ctx.taskId || extractDeviceToken(ctx.resourceId) || "run");
34
+ return `${ctx.platform.toLowerCase()}-${scope}-${Date.now()}.mp4`;
35
+ }
36
+ function buildRemoteAndroidRecordingPath(ctx) {
37
+ const scope = safeNamePart(ctx.taskId || extractDeviceToken(ctx.resourceId) || "run") || "run";
38
+ return `/sdcard/preflight-${scope}-${Date.now()}.mp4`;
39
+ }
40
+ function shQuote(value) {
41
+ return `'${value.replace(/'/g, `'\\''`)}'`;
42
+ }
43
+ function positivePort(raw) {
44
+ const n = Number(raw);
45
+ if (!Number.isFinite(n) || n <= 0)
46
+ return undefined;
47
+ return Math.floor(n);
48
+ }
49
+ function androidAdbCommandPrefix(ctx) {
50
+ const serial = envString(ctx.runtimeEnv, "MIDSCENE_ANDROID_SERIAL", extractDeviceToken(ctx.resourceId));
51
+ const host = envString(ctx.runtimeEnv, "MIDSCENE_ANDROID_ADB_HOST", "127.0.0.1");
52
+ const port = positivePort(ctx.runtimeEnv.MIDSCENE_ANDROID_ADB_PORT) ?? 5037;
53
+ return `adb -H ${shQuote(host)} -P ${port} -s ${shQuote(serial)}`;
54
+ }
55
+ function harmonyHdcCommandPrefix(ctx) {
56
+ const hdc = envString(ctx.runtimeEnv, "MIDSCENE_HARMONY_HDC_PATH", "hdc");
57
+ const host = ctx.runtimeEnv.MIDSCENE_HARMONY_HDC_HOST?.trim();
58
+ const port = positivePort(ctx.runtimeEnv.MIDSCENE_HARMONY_HDC_PORT);
59
+ const server = host && port ? ` -s ${shQuote(`${host}:${port}`)}` : "";
60
+ const deviceId = envString(ctx.runtimeEnv, "MIDSCENE_HARMONY_DEVICE_ID", extractDeviceToken(ctx.resourceId));
61
+ return `${shQuote(hdc)}${server} -t ${shQuote(deviceId)}`;
62
+ }
63
+ function resolveAutoSource(ctx) {
64
+ if (ctx.platform === PlatformType.IOS) {
65
+ const host = envString(ctx.runtimeEnv, "MIDSCENE_IOS_WDA_HOST", "127.0.0.1");
66
+ const explicitMjpegPort = positivePort(ctx.runtimeEnv.MIDSCENE_IOS_WDA_MJPEG_PORT) ??
67
+ positivePort(ctx.runtimeEnv.IOS_WDA_MJPEG_PORT);
68
+ const wdaPort = positivePort(ctx.runtimeEnv.MIDSCENE_IOS_WDA_PORT) ?? positivePort(ctx.runtimeEnv.IOS_WDA_PORT);
69
+ const mjpegPort = explicitMjpegPort ?? (wdaPort != null ? wdaPort + 1000 : undefined);
70
+ if (!mjpegPort)
71
+ return undefined;
72
+ return { kind: "url", url: `http://${host}:${mjpegPort}/` };
73
+ }
74
+ if (ctx.platform === PlatformType.ANDROID) {
75
+ return {
76
+ kind: "scrcpy-record",
77
+ serial: envString(ctx.runtimeEnv, "MIDSCENE_ANDROID_SERIAL", extractDeviceToken(ctx.resourceId)),
78
+ };
79
+ }
80
+ if (ctx.platform === PlatformType.HARMONY) {
81
+ return {
82
+ kind: "command",
83
+ command: `${harmonyHdcCommandPrefix(ctx)} shell screenrecord --output-format=h264 -`,
84
+ inputFormat: "h264",
85
+ };
86
+ }
87
+ return undefined;
88
+ }
89
+ export function resolveVideoRecorderConfig(env, context) {
90
+ const runtimeEnv = { ...env, ...context.runtimeEnv };
91
+ return {
92
+ enabled: truthy(runtimeEnv.MIDSCENE_RECORD_VIDEO_ENABLED),
93
+ source: resolveAutoSource({ ...context, runtimeEnv }),
94
+ fps: Math.floor(envNumber(runtimeEnv, "MIDSCENE_RECORD_VIDEO_FPS", 30, 1, 120)),
95
+ scaleWidth: Math.floor(envNumber(runtimeEnv, "MIDSCENE_RECORD_VIDEO_SCALE_WIDTH", 540, 120, 4096)),
96
+ crf: Math.floor(envNumber(runtimeEnv, "MIDSCENE_RECORD_VIDEO_CRF", 32, 0, 51)),
97
+ preset: envString(runtimeEnv, "MIDSCENE_RECORD_VIDEO_PRESET", "fast"),
98
+ playbackRate: envNumber(runtimeEnv, "MIDSCENE_RECORD_VIDEO_PLAYBACK_RATE", 1, 0.1, 8),
99
+ outputName: buildOutputName({ ...context, runtimeEnv }),
100
+ };
101
+ }
102
+ function buildVideoFilters(config) {
103
+ const filters = [`scale=${config.scaleWidth}:-2`];
104
+ if (config.playbackRate !== 1) {
105
+ filters.push(`setpts=${(1 / config.playbackRate).toFixed(6)}*PTS`);
106
+ }
107
+ return filters.join(",");
108
+ }
109
+ async function resolveFfmpegCommand(env) {
110
+ const explicit = env.MIDSCENE_RECORD_VIDEO_FFMPEG_PATH?.trim() || env.FFMPEG_PATH?.trim();
111
+ if (explicit)
112
+ return explicit;
113
+ try {
114
+ const mod = (await import("@ffmpeg-installer/ffmpeg"));
115
+ const bundled = mod.default?.path ?? mod.path;
116
+ if (bundled)
117
+ return bundled;
118
+ }
119
+ catch {
120
+ /* optional dependency; fall back to PATH */
121
+ }
122
+ return "ffmpeg";
123
+ }
124
+ function spawnEnv() {
125
+ return {
126
+ ...process.env,
127
+ PATH: augmentPathForShellCommands(process.env.PATH),
128
+ };
129
+ }
130
+ function buildFfmpegArgs(config, outputPath, ffmpegCommand) {
131
+ if (!config.source)
132
+ throw new Error("record video source is not available");
133
+ const inputArgs = config.source.kind === "url"
134
+ ? ["-i", config.source.url]
135
+ : config.source.kind === "command"
136
+ ? ["-f", config.source.inputFormat, "-i", "pipe:0"]
137
+ : config.source.kind === "android-screenrecord-file"
138
+ ? ["-i", config.source.remotePath]
139
+ : (() => {
140
+ throw new Error("scrcpy recording must be transcoded from its raw local file");
141
+ })();
142
+ // 对于实时流(URL / 管道),需要使用 fragmented MP4 确保中断时文件仍可播放。
143
+ // frag_keyframe+empty_moov 让 moov atom 写在文件开头,播放器无需等待完整的
144
+ // 文件尾部索引即可开始解码。
145
+ const movflags = config.source?.kind === "url" || config.source?.kind === "command"
146
+ ? ["-movflags", "frag_keyframe+empty_moov"]
147
+ : [];
148
+ return [
149
+ ffmpegCommand,
150
+ "-y",
151
+ ...inputArgs,
152
+ "-r",
153
+ String(config.fps),
154
+ "-vf",
155
+ buildVideoFilters(config),
156
+ ...movflags,
157
+ "-an",
158
+ "-c:v",
159
+ "libx264",
160
+ "-preset",
161
+ config.preset,
162
+ "-crf",
163
+ String(config.crf),
164
+ outputPath,
165
+ ];
166
+ }
167
+ export async function startVideoRecording(reportDir, config) {
168
+ if (!config.enabled)
169
+ return null;
170
+ if (!config.source) {
171
+ console.warn("[videoRecorder] enabled but no platform input source could be resolved");
172
+ return null;
173
+ }
174
+ const recordingsDir = path.join(reportDir, "recordings");
175
+ await mkdir(recordingsDir, { recursive: true });
176
+ const outputPath = path.join(recordingsDir, config.outputName);
177
+ const ffmpegCommand = await resolveFfmpegCommand(process.env);
178
+ if (config.source.kind === "scrcpy-record") {
179
+ return startScrcpyRecording(recordingsDir, outputPath, config, ffmpegCommand);
180
+ }
181
+ if (config.source.kind === "android-screenrecord-file") {
182
+ return startAndroidFileRecording(recordingsDir, outputPath, config, ffmpegCommand);
183
+ }
184
+ const args = buildFfmpegArgs(config, outputPath, ffmpegCommand).slice(1);
185
+ const sourceLabel = config.source.kind === "url" ? config.source.url : config.source.command;
186
+ const child = config.source.kind === "url"
187
+ ? spawn(ffmpegCommand, args, { stdio: ["ignore", "pipe", "pipe"], env: spawnEnv() })
188
+ : spawn("sh", ["-lc", `${config.source.command} | ${shQuote(ffmpegCommand)} ${args.map(shQuote).join(" ")}`], {
189
+ detached: true,
190
+ stdio: ["ignore", "pipe", "pipe"],
191
+ env: spawnEnv(),
192
+ });
193
+ let stderr = "";
194
+ child.stderr?.on("data", (chunk) => {
195
+ stderr += chunk.toString();
196
+ });
197
+ child.stdout?.resume();
198
+ child.on("error", (err) => {
199
+ stderr += `\n${err.message}`;
200
+ });
201
+ console.info(`[videoRecorder] started source=${sourceLabel} fps=${config.fps} width=${config.scaleWidth} crf=${config.crf} output=${outputPath}`);
202
+ return {
203
+ outputPath,
204
+ stop: () => stopVideoRecording(child, outputPath, () => stderr),
205
+ };
206
+ }
207
+ async function startScrcpyRecording(recordingsDir, outputPath, config, ffmpegCommand) {
208
+ if (!config.source || config.source.kind !== "scrcpy-record") {
209
+ throw new Error("scrcpy recording requires scrcpy-record source");
210
+ }
211
+ const rawPath = path.join(recordingsDir, `.raw-${path.basename(outputPath)}`);
212
+ const args = ["-s", config.source.serial, "--no-window", "--record", rawPath];
213
+ const child = spawn("scrcpy", args, {
214
+ detached: true,
215
+ stdio: ["ignore", "pipe", "pipe"],
216
+ env: spawnEnv(),
217
+ });
218
+ let stderr = "";
219
+ child.stderr?.on("data", (chunk) => {
220
+ stderr += chunk.toString();
221
+ });
222
+ child.stdout?.resume();
223
+ child.on("error", (err) => {
224
+ stderr += `\n${err.message}`;
225
+ });
226
+ console.info(`[videoRecorder] started source=scrcpy ${args.map(shQuote).join(" ")} fps=${config.fps} width=${config.scaleWidth} crf=${config.crf} output=${outputPath}`);
227
+ return {
228
+ outputPath,
229
+ stop: async () => {
230
+ const stoppedStderr = await stopChildProcess(child, () => stderr);
231
+ stderr = `${stderr}\n${stoppedStderr}`;
232
+ const args = buildFfmpegArgs({ ...config, source: { kind: "url", url: pathToFileURL(rawPath).toString() } }, outputPath, ffmpegCommand);
233
+ const transcode = await runProcess(args[0], args.slice(1), 120_000);
234
+ stderr = `${stderr}\n${transcode.stderr}`;
235
+ await rm(rawPath, { force: true }).catch(() => { });
236
+ return finishStoppedRecording(outputPath, stderr);
237
+ },
238
+ };
239
+ }
240
+ async function startAndroidFileRecording(recordingsDir, outputPath, config, ffmpegCommand) {
241
+ if (!config.source || config.source.kind !== "android-screenrecord-file") {
242
+ throw new Error("android file recording requires android-screenrecord-file source");
243
+ }
244
+ const rawPath = path.join(recordingsDir, `.raw-${path.basename(outputPath)}`);
245
+ const source = config.source;
246
+ const command = `${source.adbPrefix} shell rm -f ${shQuote(source.remotePath)}; exec ${source.adbPrefix} shell screenrecord ${shQuote(source.remotePath)}`;
247
+ const child = spawn("sh", ["-lc", command], {
248
+ detached: true,
249
+ stdio: ["ignore", "pipe", "pipe"],
250
+ env: spawnEnv(),
251
+ });
252
+ let stderr = "";
253
+ child.stderr?.on("data", (chunk) => {
254
+ stderr += chunk.toString();
255
+ });
256
+ child.stdout?.resume();
257
+ child.on("error", (err) => {
258
+ stderr += `\n${err.message}`;
259
+ });
260
+ console.info(`[videoRecorder] started source=${command} fps=${config.fps} width=${config.scaleWidth} crf=${config.crf} output=${outputPath}`);
261
+ return {
262
+ outputPath,
263
+ stop: async () => {
264
+ const stoppedStderr = await stopChildProcess(child, () => stderr);
265
+ stderr = `${stderr}\n${stoppedStderr}`;
266
+ const pull = await runShell(`${source.adbPrefix} pull ${shQuote(source.remotePath)} ${shQuote(rawPath)}`, 30_000);
267
+ stderr = `${stderr}\n${pull.stderr}`;
268
+ if (!pull.ok) {
269
+ await runShell(`${source.adbPrefix} shell rm -f ${shQuote(source.remotePath)}`, 10_000).catch(() => { });
270
+ return finishStoppedRecording(outputPath, stderr);
271
+ }
272
+ const args = buildFfmpegArgs({ ...config, source: { kind: "url", url: pathToFileURL(rawPath).toString() } }, outputPath, ffmpegCommand);
273
+ const transcode = await runProcess(args[0], args.slice(1), 120_000);
274
+ stderr = `${stderr}\n${transcode.stderr}`;
275
+ await runShell(`${source.adbPrefix} shell rm -f ${shQuote(source.remotePath)}`, 10_000).catch(() => { });
276
+ await rm(rawPath, { force: true }).catch(() => { });
277
+ return finishStoppedRecording(outputPath, stderr);
278
+ },
279
+ };
280
+ }
281
+ async function stopVideoRecording(child, outputPath, getStderr) {
282
+ const stderr = await stopChildProcess(child, getStderr);
283
+ return finishStoppedRecording(outputPath, stderr);
284
+ }
285
+ async function stopChildProcess(child, getStderr) {
286
+ if (child.exitCode != null || child.killed) {
287
+ return getStderr();
288
+ }
289
+ if (child.pid != null && child.pid > 0) {
290
+ try {
291
+ process.kill(-child.pid, "SIGINT");
292
+ }
293
+ catch {
294
+ child.kill("SIGINT");
295
+ }
296
+ }
297
+ return new Promise((resolve) => {
298
+ const done = () => resolve(getStderr());
299
+ const timer = setTimeout(() => {
300
+ if (child.pid != null && child.pid > 0) {
301
+ try {
302
+ process.kill(-child.pid, "SIGTERM");
303
+ }
304
+ catch {
305
+ if (child.exitCode == null && !child.killed)
306
+ child.kill("SIGTERM");
307
+ }
308
+ }
309
+ setTimeout(() => {
310
+ if (child.exitCode == null && !child.killed) {
311
+ try {
312
+ child.kill("SIGKILL");
313
+ }
314
+ catch {
315
+ /* already gone */
316
+ }
317
+ }
318
+ resolve(getStderr());
319
+ }, 3_000);
320
+ }, 8_000);
321
+ child.once("close", () => {
322
+ clearTimeout(timer);
323
+ done();
324
+ });
325
+ });
326
+ }
327
+ function runShell(command, timeoutMs) {
328
+ return runProcess("sh", ["-lc", command], timeoutMs);
329
+ }
330
+ function runProcess(command, args, timeoutMs) {
331
+ return new Promise((resolve) => {
332
+ const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"], env: spawnEnv() });
333
+ let stderr = "";
334
+ const timer = setTimeout(() => {
335
+ child.kill("SIGTERM");
336
+ }, timeoutMs);
337
+ child.stdout?.resume();
338
+ child.stderr?.on("data", (chunk) => {
339
+ stderr += chunk.toString();
340
+ });
341
+ child.on("error", (err) => {
342
+ clearTimeout(timer);
343
+ resolve({ ok: false, stderr: `${stderr}\n${err.message}` });
344
+ });
345
+ child.on("close", (code) => {
346
+ clearTimeout(timer);
347
+ resolve({ ok: code === 0, stderr });
348
+ });
349
+ });
350
+ }
351
+ async function finishStoppedRecording(outputPath, stderr) {
352
+ try {
353
+ const st = await stat(outputPath);
354
+ if (st.isFile() && st.size > 0) {
355
+ console.info(`[videoRecorder] stopped output=${outputPath} bytes=${st.size}`);
356
+ return { ok: true, outputPath, stderr };
357
+ }
358
+ }
359
+ catch {
360
+ /* missing file */
361
+ }
362
+ console.warn(`[videoRecorder] stopped without output=${outputPath} stderr=${stderr.slice(-500)}`);
363
+ await writeFile(`${outputPath}.stderr.log`, stderr || "video recording produced no output", "utf8").catch(() => { });
364
+ return { ok: false, stderr };
365
+ }
@@ -0,0 +1,36 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { mkdtemp, readFile, rm } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import path from "node:path";
6
+ const execFileAsync = promisify(execFile);
7
+ /**
8
+ * 将目录打成 zip 文件,返回 **临时** zip 路径。调用方负责在合适时机删除整个临时目录或 zip。
9
+ * 依赖系统 `zip` 可执行文件(macOS / 常见 Linux)。
10
+ */
11
+ export async function zipDirectoryToFile(bundleDir, stem) {
12
+ if (!bundleDir)
13
+ return undefined;
14
+ const outDir = await mkdtemp(path.join(tmpdir(), "report-zip-"));
15
+ const outZip = path.join(outDir, `${stem}-bundle.zip`);
16
+ try {
17
+ await execFileAsync("zip", ["-q", "-r", outZip, "."], { cwd: bundleDir });
18
+ }
19
+ catch {
20
+ await rm(outDir, { recursive: true, force: true });
21
+ return undefined;
22
+ }
23
+ return outZip;
24
+ }
25
+ export async function readFileAsBase64(filePath) {
26
+ const buf = await readFile(filePath);
27
+ return buf.toString("base64");
28
+ }
29
+ export async function rmQuiet(target) {
30
+ try {
31
+ await rm(target, { recursive: true, force: true });
32
+ }
33
+ catch {
34
+ /* ignore */
35
+ }
36
+ }