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.
- package/LICENSE +201 -0
- package/README.md +144 -0
- package/dist/adapter-spi/artifact/index.js +1 -0
- package/dist/adapter-spi/command/index.js +1 -0
- package/dist/adapter-spi/install/index.js +1 -0
- package/dist/adapter-spi/lifecycle/index.js +1 -0
- package/dist/adapter-spi/resource/index.js +1 -0
- package/dist/adapter-spi/snapshot/index.js +1 -0
- package/dist/application/agent/AgentCommandPollLoop.js +48 -0
- package/dist/application/agent/AgentRuntimeService.js +27 -0
- package/dist/application/app-package/AppPackageApplicationService.js +97 -0
- package/dist/application/artifact/ArtifactApplicationService.js +13 -0
- package/dist/application/debug/DebugApplicationService.js +117 -0
- package/dist/application/health/HealthMetricsService.js +47 -0
- package/dist/application/lease/LeaseApplicationService.js +79 -0
- package/dist/application/query/ObservationQueryService.js +48 -0
- package/dist/application/reporter/ReporterApplicationService.js +41 -0
- package/dist/application/resource/ResourceOccupationReleaseService.js +49 -0
- package/dist/application/resource/ResourceRegistryService.js +113 -0
- package/dist/application/session/SessionApplicationService.js +39 -0
- package/dist/application/task/TaskApplicationService.js +378 -0
- package/dist/client/agentAppPackageClient.js +91 -0
- package/dist/domain/agent/AgentNode.js +12 -0
- package/dist/domain/agent/AgentRuntime.js +6 -0
- package/dist/domain/artifact/ArtifactPipeline.js +6 -0
- package/dist/domain/artifact/ArtifactRef.js +12 -0
- package/dist/domain/event/AgentEvent.js +10 -0
- package/dist/domain/event/Reporter.js +6 -0
- package/dist/domain/health/HealthMetrics.js +8 -0
- package/dist/domain/lease/Lease.js +28 -0
- package/dist/domain/lease/LeaseManager.js +6 -0
- package/dist/domain/repositories/index.js +1 -0
- package/dist/domain/resource/DeviceDetails.js +23 -0
- package/dist/domain/resource/DeviceResource.js +16 -0
- package/dist/domain/resource/ResourceRegistry.js +6 -0
- package/dist/domain/runtime/interfaces.js +1 -0
- package/dist/domain/session/BaseSession.js +16 -0
- package/dist/domain/session/DebugSession.js +3 -0
- package/dist/domain/session/ExecutionSession.js +3 -0
- package/dist/domain/session/SessionManager.js +8 -0
- package/dist/domain/task/TaskRecord.js +14 -0
- package/dist/domain/task/TaskSpec.js +12 -0
- package/dist/infrastructure/adapters/AdapterRegistry.js +10 -0
- package/dist/infrastructure/adapters/BridgeAdapters.js +6 -0
- package/dist/infrastructure/adapters/android/AndroidResourceAdapter.js +51 -0
- package/dist/infrastructure/adapters/deviceDetailsProbe.js +229 -0
- package/dist/infrastructure/adapters/harmony/HarmonyResourceAdapter.js +40 -0
- package/dist/infrastructure/adapters/ios/IOSResourceAdapter.js +182 -0
- package/dist/infrastructure/adapters/real/ShellCommandAndSnapshot.js +41 -0
- package/dist/infrastructure/airtest/AirtestRuntime.js +168 -0
- package/dist/infrastructure/app-package/AppPackageUrlCache.js +191 -0
- package/dist/infrastructure/app-package/appPackageDownloadDir.js +13 -0
- package/dist/infrastructure/bootstrap/BuildRuntimeContext.js +88 -0
- package/dist/infrastructure/cache/DirCapacityWatchdog.js +150 -0
- package/dist/infrastructure/config/agentConfigFile.js +96 -0
- package/dist/infrastructure/device/DeviceAppPackageOps.js +146 -0
- package/dist/infrastructure/ios/IOSWdaWatchdog.js +207 -0
- package/dist/infrastructure/live-debug/LiveDebugSessionManager.js +74 -0
- package/dist/infrastructure/live-debug/RuntimeLiveDebugAdapters.js +19 -0
- package/dist/infrastructure/midscene/DebugRuntimeImpl.js +533 -0
- package/dist/infrastructure/midscene/MidsceneRuntimeMock.js +22 -0
- package/dist/infrastructure/midscene/MidsceneRuntimeReal.js +552 -0
- package/dist/infrastructure/midscene/executionDumpWatcher.js +219 -0
- package/dist/infrastructure/midscene/videoRecorder.js +365 -0
- package/dist/infrastructure/midscene/zipReportDir.js +36 -0
- package/dist/infrastructure/persistence/InMemoryRepositories.js +94 -0
- package/dist/infrastructure/resilience/DeliveryIdDeduper.js +26 -0
- package/dist/infrastructure/system/CommandRunner.js +128 -0
- package/dist/infrastructure/transport/http/AgentEventHttpIngestClient.js +52 -0
- package/dist/infrastructure/transport/http/CallbackOutboxStore.js +106 -0
- package/dist/infrastructure/transport/http/PlatformCallbackClient.js +113 -0
- package/dist/infrastructure/transport/http/PlatformCommandPollClient.js +89 -0
- package/dist/infrastructure/transport/http/ResilientPlatformCallbackClient.js +117 -0
- package/dist/infrastructure/transport/midscenePaths.js +28 -0
- package/dist/infrastructure/transport/ws/ResilientWsOrHttpEventPublisher.js +29 -0
- package/dist/infrastructure/transport/ws/WsClient.js +182 -0
- package/dist/infrastructure/transport/ws/WsEventPublisher.js +36 -0
- package/dist/interfaces/http/HttpServer.js +227 -0
- package/dist/interfaces/websocket/AgentWsGateway.js +227 -0
- package/dist/main.js +368 -0
- package/dist/mcp/agentHttpClient.js +82 -0
- package/dist/mcp/agentRuntime.js +184 -0
- package/dist/mcp/cli.js +36 -0
- package/dist/mcp/doctor.js +124 -0
- package/dist/mcp/evidence.js +57 -0
- package/dist/mcp/exploration/index.js +129 -0
- package/dist/mcp/exploration/sessionManager.js +122 -0
- package/dist/mcp/exploration/tools-atomic.js +34 -0
- package/dist/mcp/exploration/tools-intelligent.js +33 -0
- package/dist/mcp/exploration/tools-session.js +276 -0
- package/dist/mcp/exploration/types.js +1 -0
- package/dist/mcp/flowStepEvents.js +114 -0
- package/dist/mcp/liveViewer.js +156 -0
- package/dist/mcp/reportReader.js +157 -0
- package/dist/mcp/runManager.js +161 -0
- package/dist/mcp/runSummary.js +72 -0
- package/dist/mcp/runtimeInstall.js +44 -0
- package/dist/mcp/server.js +260 -0
- package/dist/mcp/setup.js +185 -0
- package/dist/mcp/types.js +1 -0
- package/dist/mcp/userConfig.js +45 -0
- package/dist/mcp/visual-flow/codegen.js +576 -0
- package/dist/mcp/visual-flow/index.js +14 -0
- package/dist/mcp/visual-flow/types.js +5 -0
- package/dist/mcp/visual-flow/validate.js +617 -0
- package/dist/protocol-contracts/commands/envelope.js +24 -0
- package/dist/protocol-contracts/commands/index.js +1 -0
- package/dist/protocol-contracts/dto/index.js +1 -0
- package/dist/protocol-contracts/events/index.js +1 -0
- package/dist/protocol-contracts/queries/index.js +1 -0
- package/dist/shared-kernel/enums/index.js +70 -0
- package/dist/shared-kernel/errors/index.js +21 -0
- package/dist/shared-kernel/ids/index.js +18 -0
- package/dist/shared-kernel/time/index.js +3 -0
- package/dist/shared-kernel/value-objects/index.js +1 -0
- package/dist/utils/appPackageLocalPath.js +75 -0
- package/dist/utils/deviceResourceRouting.js +24 -0
- package/dist/utils/harmonyAgentDebugDevice.js +60 -0
- package/dist/utils/harmonyHdcDeviceId.js +134 -0
- package/dist/utils/iosAgentDebugDevice.js +72 -0
- package/dist/utils/iosMjpegCapture.js +90 -0
- package/dist/utils/liveDebugForegroundParse.js +71 -0
- package/dist/utils/midscene-device-session.js +353 -0
- package/dist/utils/midscene-task-cache-env.js +15 -0
- package/dist/utils/midsceneReportConstants.js +49 -0
- package/dist/utils/seedMidsceneTaskCache.js +61 -0
- package/dist/utils/task-runners/context/androidTaskRunnerContext.js +1 -0
- package/dist/utils/task-runners/context/harmonyTaskRunnerContext.js +1 -0
- package/dist/utils/task-runners/context/iosTaskRunnerContext.js +1 -0
- package/dist/utils/task-runners/runAndroidNativeAppTask.js +29 -0
- package/dist/utils/task-runners/runHarmonyNativeAppTask.js +36 -0
- package/dist/utils/task-runners/runIosNativeAppTask.js +30 -0
- package/dist/utils/task-runners/taskAppPackage.js +20 -0
- package/dist/utils/wrapper/resolveTaskRunnerImport.js +11 -0
- package/dist/utils/wrapper/wrapAndroidTaskScript.js +38 -0
- package/dist/utils/wrapper/wrapHarmonyTaskScript.js +42 -0
- package/dist/utils/wrapper/wrapIosTaskScript.js +30 -0
- package/package.json +46 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { resolveSession } from "./tools-session.js";
|
|
2
|
+
/**
|
|
3
|
+
* Parse a data URI and return the base64 payload and MIME type.
|
|
4
|
+
* Falls back to `mimeType: "unknown"` when the data URI cannot be parsed.
|
|
5
|
+
*/
|
|
6
|
+
function parseDataUri(uri) {
|
|
7
|
+
const match = /^data:([^;]+);base64,(.+)$/.exec(uri);
|
|
8
|
+
if (!match) {
|
|
9
|
+
return { screenshot: uri, mimeType: "unknown" };
|
|
10
|
+
}
|
|
11
|
+
return { screenshot: match[2], mimeType: match[1] };
|
|
12
|
+
}
|
|
13
|
+
export function getScreenshotHandler(ctx) {
|
|
14
|
+
return async (input) => {
|
|
15
|
+
const session = await resolveSession(input.sessionId, ctx);
|
|
16
|
+
const rawDataUri = await session.device.screenshotBase64();
|
|
17
|
+
return parseDataUri(rawDataUri);
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
export function getTypeHandler(ctx) {
|
|
21
|
+
return async (input) => {
|
|
22
|
+
const session = await resolveSession(input.sessionId, ctx);
|
|
23
|
+
await session.agent.aiAct(`输入 ${input.text}`);
|
|
24
|
+
return { ok: true };
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
export function getWaitHandler(ctx) {
|
|
28
|
+
return async (input) => {
|
|
29
|
+
const session = await resolveSession(input.sessionId, ctx);
|
|
30
|
+
const clamped = Math.max(0, Math.min(input.ms, 10000));
|
|
31
|
+
await new Promise((r) => setTimeout(r, clamped));
|
|
32
|
+
return { ok: true };
|
|
33
|
+
};
|
|
34
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { resolveSession } from "./tools-session.js";
|
|
2
|
+
export function getPageSummaryHandler(ctx) {
|
|
3
|
+
return async (input) => {
|
|
4
|
+
const session = await resolveSession(input.sessionId, ctx);
|
|
5
|
+
const summary = await session.agent.aiAsk("详细描述当前页面。请按从上到下的顺序列出所有可见区域、交互元素和文案。\n" +
|
|
6
|
+
"特别注意:\n" +
|
|
7
|
+
"1) 页面底部是否有更多内容(是否可滚动)?如果底部紧贴导航栏/状态栏则说明是固定单屏布局\n" +
|
|
8
|
+
"2) 是否有弹窗、广告或遮挡物?\n" +
|
|
9
|
+
"3) 整体布局类型:固定单屏 / 可滚动长页面 / 多Tab / 列表\n" +
|
|
10
|
+
"先判断布局类型,再逐一描述每个区域的内容。");
|
|
11
|
+
return { summary };
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
export function askAboutScreenHandler(ctx) {
|
|
15
|
+
return async (input) => {
|
|
16
|
+
const session = await resolveSession(input.sessionId, ctx);
|
|
17
|
+
const answer = await session.agent.aiAsk(input.question);
|
|
18
|
+
return { answer };
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
export function aiActHandler(ctx) {
|
|
22
|
+
return async (input) => {
|
|
23
|
+
const session = await resolveSession(input.sessionId, ctx);
|
|
24
|
+
await session.agent.aiAct(input.intent);
|
|
25
|
+
const afterSummary = await session.agent.aiAsk("刚刚的操作已完成。请判断:\n" +
|
|
26
|
+
"1) 操作是否改变了页面内容?(新页面、弹窗、滚动到底部、输入框获得焦点等)\n" +
|
|
27
|
+
"2) 如果操作是滑动页面,是否滑到了底部或页面内容没有变化?\n" +
|
|
28
|
+
"3) 当前页面布局类型是固定单屏还是可滚动长页面?\n" +
|
|
29
|
+
"4) 当前页面出现的最关键变化是什么?\n" +
|
|
30
|
+
"如果你发现操作没有产生任何实际变化(比如反复滑动但没有新内容),请明确指出\"页面没有变化\"。");
|
|
31
|
+
return { ok: true, summary: afterSummary };
|
|
32
|
+
};
|
|
33
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import http from "node:http";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import { createSession, getSession, destroySession, destroySessionById } from "./sessionManager.js";
|
|
7
|
+
import { createMidsceneSession } from "../../utils/midscene-device-session.js";
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Auto-discover WDA port from watchdog state file
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
async function discoverWdaPort(deviceId) {
|
|
12
|
+
const statePath = process.env.IOS_WDA_PORT_MAP_FILE_PATH
|
|
13
|
+
|| path.join(homedir(), ".preflight", "runtime", ".wda-agent-state", "wda-port-map.json");
|
|
14
|
+
try {
|
|
15
|
+
const raw = await readFile(statePath, "utf8");
|
|
16
|
+
const parsed = JSON.parse(raw);
|
|
17
|
+
const port = parsed?.portsByUdid?.[deviceId];
|
|
18
|
+
if (port && Number.isFinite(port) && port > 0)
|
|
19
|
+
return Math.floor(port);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
// state file not found or malformed — will use default port
|
|
23
|
+
}
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// WDA health check and auto-start for iOS exploration
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
async function checkWdaHealth(host, port, timeoutMs) {
|
|
30
|
+
return new Promise((resolve) => {
|
|
31
|
+
const req = http.get(`http://${host}:${port}/status`, { timeout: timeoutMs }, (res) => {
|
|
32
|
+
let body = "";
|
|
33
|
+
res.on("data", (chunk) => { body += chunk; });
|
|
34
|
+
res.on("end", () => resolve(body.trim().length > 0));
|
|
35
|
+
});
|
|
36
|
+
req.on("error", () => resolve(false));
|
|
37
|
+
req.on("timeout", () => { req.destroy(); resolve(false); });
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Ensure WebDriverAgent is running for the given iOS device.
|
|
42
|
+
* If already healthy, returns immediately. Otherwise invokes start-ios-wda.sh
|
|
43
|
+
* and waits up to 120s for it to become ready.
|
|
44
|
+
*/
|
|
45
|
+
export async function ensureIosWdaStarted(resourceId, runtimeEnv, projectRoot) {
|
|
46
|
+
const host = runtimeEnv.MIDSCENE_IOS_WDA_HOST || "127.0.0.1";
|
|
47
|
+
const defaultPort = Number(runtimeEnv.MIDSCENE_IOS_WDA_PORT) || 8200;
|
|
48
|
+
const udid = extractDeviceValue(resourceId);
|
|
49
|
+
// Discover port from watchdog port map, else use default
|
|
50
|
+
const discoveredPort = udid ? await discoverWdaPort(udid) : null;
|
|
51
|
+
const port = discoveredPort ?? defaultPort;
|
|
52
|
+
// Already healthy?
|
|
53
|
+
if (await checkWdaHealth(host, port, 2000)) {
|
|
54
|
+
return { ok: true, message: `WebDriverAgent is already healthy on ${host}:${port}.`, host, port };
|
|
55
|
+
}
|
|
56
|
+
// Resolve start-ios-wda.sh script path
|
|
57
|
+
const resolvedProjectRoot = projectRoot || process.cwd();
|
|
58
|
+
const scriptPath = path.join(resolvedProjectRoot, "scripts", "start-ios-wda.sh");
|
|
59
|
+
if (!udid) {
|
|
60
|
+
return { ok: false, message: "Cannot start WDA: no iOS device UDID found in resourceId.", host, port };
|
|
61
|
+
}
|
|
62
|
+
// Start WDA via script
|
|
63
|
+
return new Promise((resolve) => {
|
|
64
|
+
const child = spawn("/bin/bash", [scriptPath, udid, String(port)], {
|
|
65
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
66
|
+
timeout: 120_000,
|
|
67
|
+
});
|
|
68
|
+
let stdout = "";
|
|
69
|
+
let stderr = "";
|
|
70
|
+
child.stdout?.on("data", (chunk) => { stdout += String(chunk); });
|
|
71
|
+
child.stderr?.on("data", (chunk) => { stderr += String(chunk); });
|
|
72
|
+
child.on("close", async (code) => {
|
|
73
|
+
if (code === 0) {
|
|
74
|
+
// Script succeeded — now wait for WDA to be actually healthy
|
|
75
|
+
for (let attempt = 0; attempt < 30; attempt++) {
|
|
76
|
+
if (await checkWdaHealth(host, port, 1000)) {
|
|
77
|
+
resolve({ ok: true, message: `WebDriverAgent started successfully on ${host}:${port}.`, host, port });
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
81
|
+
}
|
|
82
|
+
resolve({
|
|
83
|
+
ok: false,
|
|
84
|
+
message: `start-ios-wda.sh exited 0 but WDA not healthy on ${host}:${port} after 30s. stdout: ${stdout.slice(0, 500)}`,
|
|
85
|
+
host, port,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
resolve({
|
|
90
|
+
ok: false,
|
|
91
|
+
message: `start-ios-wda.sh exited code ${code}. stderr: ${stderr.slice(0, 300)}`,
|
|
92
|
+
host, port,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
child.on("error", (err) => {
|
|
97
|
+
resolve({
|
|
98
|
+
ok: false,
|
|
99
|
+
message: `Failed to start WDA script: ${err.message}`,
|
|
100
|
+
host, port,
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
// Exported helpers (used by other exploration tools for session recovery)
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
export function generateSessionId() {
|
|
109
|
+
return `explore-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
110
|
+
}
|
|
111
|
+
export function parsePlatform(resourceId) {
|
|
112
|
+
const colonIdx = resourceId.indexOf(":");
|
|
113
|
+
if (colonIdx < 0) {
|
|
114
|
+
throw new Error(`Invalid resourceId format: "${resourceId}". Expected "platform:id"`);
|
|
115
|
+
}
|
|
116
|
+
const prefix = resourceId.slice(0, colonIdx);
|
|
117
|
+
switch (prefix) {
|
|
118
|
+
case "android":
|
|
119
|
+
return "ANDROID";
|
|
120
|
+
case "ios":
|
|
121
|
+
return "IOS";
|
|
122
|
+
case "harmony":
|
|
123
|
+
return "HARMONY";
|
|
124
|
+
default:
|
|
125
|
+
throw new Error(`Unknown platform prefix in resourceId: "${resourceId}"`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
function extractDeviceValue(resourceId) {
|
|
129
|
+
const colonIdx = resourceId.indexOf(":");
|
|
130
|
+
return colonIdx >= 0 ? resourceId.slice(colonIdx + 1) : "";
|
|
131
|
+
}
|
|
132
|
+
export async function createMidsceneSessionFromResourceId(resourceId, runtimeEnv) {
|
|
133
|
+
const platform = parsePlatform(resourceId);
|
|
134
|
+
const value = extractDeviceValue(resourceId);
|
|
135
|
+
switch (platform) {
|
|
136
|
+
case "ANDROID": {
|
|
137
|
+
const adbPortRaw = runtimeEnv.MIDSCENE_ANDROID_ADB_PORT;
|
|
138
|
+
const adbPort = adbPortRaw ? Number(adbPortRaw) : 5037;
|
|
139
|
+
return createMidsceneSession({
|
|
140
|
+
platform: "android",
|
|
141
|
+
serial: value || undefined,
|
|
142
|
+
adbHost: runtimeEnv.MIDSCENE_ANDROID_ADB_HOST ?? "127.0.0.1",
|
|
143
|
+
adbPort,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
case "IOS": {
|
|
147
|
+
const udid = value || undefined;
|
|
148
|
+
const discoveredPort = await discoverWdaPort(udid || "");
|
|
149
|
+
const wdaPort = runtimeEnv.MIDSCENE_IOS_WDA_PORT
|
|
150
|
+
? Number(runtimeEnv.MIDSCENE_IOS_WDA_PORT)
|
|
151
|
+
: discoveredPort ?? 8200;
|
|
152
|
+
const logPort = wdaPort === discoveredPort ? `${wdaPort} (discovered)` : `${wdaPort} (default)`;
|
|
153
|
+
console.log(`[ios-target] ${udid || "auto"} WDA port: ${logPort}`);
|
|
154
|
+
return createMidsceneSession({
|
|
155
|
+
platform: "ios",
|
|
156
|
+
deviceId: udid,
|
|
157
|
+
wdaHost: runtimeEnv.MIDSCENE_IOS_WDA_HOST ?? "127.0.0.1",
|
|
158
|
+
wdaPort,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
case "HARMONY":
|
|
162
|
+
return createMidsceneSession({
|
|
163
|
+
platform: "harmony",
|
|
164
|
+
deviceId: value || undefined,
|
|
165
|
+
hdcPath: runtimeEnv.MIDSCENE_HARMONY_HDC_PATH || undefined,
|
|
166
|
+
});
|
|
167
|
+
default:
|
|
168
|
+
const _exhaustive = platform;
|
|
169
|
+
throw new Error(`Unsupported platform: ${_exhaustive}`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
function isInstallableRef(appRef) {
|
|
173
|
+
const lower = appRef.toLowerCase();
|
|
174
|
+
return (lower.startsWith("http") ||
|
|
175
|
+
lower.startsWith("file") ||
|
|
176
|
+
lower.endsWith(".apk") ||
|
|
177
|
+
lower.endsWith(".ipa"));
|
|
178
|
+
}
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
// Session manager helpers (shared across all exploration tools)
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
/**
|
|
183
|
+
* Resolve a session by ID, with automatic recovery across MCP server restarts.
|
|
184
|
+
* 1. Try in-memory lookup (fast path)
|
|
185
|
+
* 2. If not found, try to load session metadata from disk
|
|
186
|
+
* 3. If metadata found, re-create the device connection using the context factory
|
|
187
|
+
* 4. Store the recovered session in memory for subsequent calls
|
|
188
|
+
*/
|
|
189
|
+
export async function resolveSession(id, ctx) {
|
|
190
|
+
try {
|
|
191
|
+
const state = getSession(id);
|
|
192
|
+
return state.session;
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
// Session not in memory — try to recover from disk
|
|
196
|
+
}
|
|
197
|
+
const { loadMeta, storeSession } = await import("./sessionManager.js");
|
|
198
|
+
const meta = await loadMeta(id);
|
|
199
|
+
if (!meta) {
|
|
200
|
+
throw new Error(`Exploration session not found: ${id}. Call exploration_start first.`);
|
|
201
|
+
}
|
|
202
|
+
// Inject env vars for the Midscene SDK
|
|
203
|
+
for (const [key, value] of Object.entries(meta.env)) {
|
|
204
|
+
if (value !== undefined && !(key in process.env)) {
|
|
205
|
+
process.env[key] = value;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
const session = await createMidsceneSessionFromResourceId(meta.resourceId, meta.env);
|
|
209
|
+
storeSession(id, session, meta);
|
|
210
|
+
return session;
|
|
211
|
+
}
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
// Tool handlers
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
export function getExplorationStartHandler(ctx) {
|
|
216
|
+
return async (input) => {
|
|
217
|
+
await ctx.ensureAgentStarted();
|
|
218
|
+
const configEnv = await ctx.loadConfigEnv();
|
|
219
|
+
const runtimeEnv = { ...configEnv };
|
|
220
|
+
for (const [key, value] of Object.entries(runtimeEnv)) {
|
|
221
|
+
if (value !== undefined && !(key in process.env)) {
|
|
222
|
+
process.env[key] = value;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
let resourceId = input.resourceId;
|
|
226
|
+
if (!resourceId) {
|
|
227
|
+
const devices = await ctx.client.listDevices();
|
|
228
|
+
if (devices.length === 0) {
|
|
229
|
+
throw new Error("No devices available. Provide a resourceId or connect a device first.");
|
|
230
|
+
}
|
|
231
|
+
resourceId = devices[0].id;
|
|
232
|
+
}
|
|
233
|
+
const platform = parsePlatform(resourceId);
|
|
234
|
+
// Auto-start WDA for iOS if not running
|
|
235
|
+
if (platform === "IOS") {
|
|
236
|
+
const wdaResult = await ensureIosWdaStarted(resourceId, runtimeEnv, ctx.projectRoot);
|
|
237
|
+
if (!wdaResult.ok) {
|
|
238
|
+
throw new Error(`iOS WebDriverAgent is not running and could not be auto-started: ${wdaResult.message}. ` +
|
|
239
|
+
`Call "start_ios_wda" tool explicitly if needed.`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
const session = await createMidsceneSessionFromResourceId(resourceId, runtimeEnv);
|
|
243
|
+
const sessionId = generateSessionId();
|
|
244
|
+
createSession(sessionId, resourceId, platform, session, runtimeEnv);
|
|
245
|
+
try {
|
|
246
|
+
if (input.appRef) {
|
|
247
|
+
if (isInstallableRef(input.appRef)) {
|
|
248
|
+
await ctx.client.installApp(resourceId, input.appRef);
|
|
249
|
+
return {
|
|
250
|
+
sessionId,
|
|
251
|
+
device: { platform, resourceId },
|
|
252
|
+
note: `App installed from ${input.appRef}. Use exploration_ai_act to launch it.`,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
await session.agent.launch(input.appRef);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
catch (err) {
|
|
259
|
+
await destroySessionById(sessionId).catch(() => { });
|
|
260
|
+
throw err;
|
|
261
|
+
}
|
|
262
|
+
return { sessionId, device: { platform, resourceId } };
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
export function getExplorationEndHandler() {
|
|
266
|
+
return async (input) => {
|
|
267
|
+
try {
|
|
268
|
+
const state = getSession(input.sessionId);
|
|
269
|
+
await destroySession(state);
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
// Session may already be expired or destroyed — always return ok
|
|
273
|
+
}
|
|
274
|
+
return { ok: true };
|
|
275
|
+
};
|
|
276
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
const PREFIX = "__FLOW_STEP_EVENT__";
|
|
2
|
+
export function extractFlowStepEventsFromRun(run) {
|
|
3
|
+
const out = [];
|
|
4
|
+
for (const event of run.events) {
|
|
5
|
+
for (const text of candidateTexts(event)) {
|
|
6
|
+
out.push(...extractFlowStepEventsFromText(text));
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
return out.sort((a, b) => a.ts - b.ts || a.stepIndex - b.stepIndex);
|
|
10
|
+
}
|
|
11
|
+
export function extractFlowStepEventsFromText(text) {
|
|
12
|
+
const out = [];
|
|
13
|
+
for (const line of text.split(/\r?\n/)) {
|
|
14
|
+
const idx = line.indexOf(PREFIX);
|
|
15
|
+
if (idx < 0)
|
|
16
|
+
continue;
|
|
17
|
+
const json = line.slice(idx + PREFIX.length).trim();
|
|
18
|
+
try {
|
|
19
|
+
const parsed = JSON.parse(json);
|
|
20
|
+
const type = parsed.type;
|
|
21
|
+
const stepIndex = Number(parsed.stepIndex);
|
|
22
|
+
const ts = Number(parsed.ts);
|
|
23
|
+
if ((type !== "start" && type !== "end" && type !== "error") || !Number.isFinite(stepIndex) || !Number.isFinite(ts)) {
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
out.push({
|
|
27
|
+
type,
|
|
28
|
+
stepIndex,
|
|
29
|
+
ts,
|
|
30
|
+
...(Number.isFinite(Number(parsed.durationMs)) ? { durationMs: Number(parsed.durationMs) } : {}),
|
|
31
|
+
...(typeof parsed.message === "string" ? { message: parsed.message } : {}),
|
|
32
|
+
...(Number.isFinite(Number(parsed.iteration)) ? { iteration: Number(parsed.iteration) } : {}),
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
// Ignore non-JSON log lines.
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
export function buildFlowStepView(visualFlow, events) {
|
|
42
|
+
const steps = flattenVisualFlowSteps(visualFlow);
|
|
43
|
+
const latestByStep = new Map();
|
|
44
|
+
for (const event of events)
|
|
45
|
+
latestByStep.set(event.stepIndex, event);
|
|
46
|
+
const current = [...events].reverse().find((event) => event.type === "start" || event.type === "error");
|
|
47
|
+
return {
|
|
48
|
+
currentStepIndex: current?.stepIndex,
|
|
49
|
+
events,
|
|
50
|
+
steps: steps.map((step) => {
|
|
51
|
+
const latest = latestByStep.get(step.index);
|
|
52
|
+
return {
|
|
53
|
+
...step,
|
|
54
|
+
status: latest?.type === "end" ? "passed" : latest?.type === "error" ? "failed" : latest?.type === "start" ? "running" : "pending",
|
|
55
|
+
...(latest?.durationMs != null ? { durationMs: latest.durationMs } : {}),
|
|
56
|
+
...(latest?.message ? { message: latest.message } : {}),
|
|
57
|
+
};
|
|
58
|
+
}),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
function candidateTexts(event) {
|
|
62
|
+
const payload = event.payload ?? {};
|
|
63
|
+
const keys = ["chunk", "message", "log", "text", "stdout", "stderr"];
|
|
64
|
+
const out = [];
|
|
65
|
+
for (const key of keys) {
|
|
66
|
+
const value = payload[key];
|
|
67
|
+
if (typeof value === "string" && value.includes(PREFIX))
|
|
68
|
+
out.push(value);
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
function flattenVisualFlowSteps(visualFlow) {
|
|
73
|
+
const root = visualFlow && typeof visualFlow === "object" ? visualFlow : {};
|
|
74
|
+
const steps = Array.isArray(root.steps) ? root.steps : [];
|
|
75
|
+
const out = [];
|
|
76
|
+
let index = 0;
|
|
77
|
+
const walk = (items, depth) => {
|
|
78
|
+
for (const item of items) {
|
|
79
|
+
if (!item || typeof item !== "object" || Array.isArray(item))
|
|
80
|
+
continue;
|
|
81
|
+
const step = item;
|
|
82
|
+
const type = typeof step.type === "string" ? step.type : "unknown";
|
|
83
|
+
index += 1;
|
|
84
|
+
out.push({ index, type, title: describeStep(type, step), depth });
|
|
85
|
+
if (type === "if" || type === "ifDeviceType") {
|
|
86
|
+
if (Array.isArray(step.thenSteps))
|
|
87
|
+
walk(step.thenSteps, depth + 1);
|
|
88
|
+
if (Array.isArray(step.elseSteps))
|
|
89
|
+
walk(step.elseSteps, depth + 1);
|
|
90
|
+
}
|
|
91
|
+
else if (type === "whileLoop" || type === "forLoop") {
|
|
92
|
+
if (Array.isArray(step.bodySteps))
|
|
93
|
+
walk(step.bodySteps, depth + 1);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
walk(steps, 0);
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
function describeStep(type, step) {
|
|
101
|
+
const primary = stringField(step, "prompt") ??
|
|
102
|
+
stringField(step, "locatePrompt") ??
|
|
103
|
+
stringField(step, "conditionPrompt") ??
|
|
104
|
+
stringField(step, "packageName") ??
|
|
105
|
+
stringField(step, "bundleId") ??
|
|
106
|
+
stringField(step, "appRef") ??
|
|
107
|
+
stringField(step, "expression") ??
|
|
108
|
+
stringField(step, "title");
|
|
109
|
+
return primary ? `${type}: ${primary}` : type;
|
|
110
|
+
}
|
|
111
|
+
function stringField(step, key) {
|
|
112
|
+
const value = step[key];
|
|
113
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
114
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
export async function startLiveViewer(port, runManager) {
|
|
3
|
+
let lastError;
|
|
4
|
+
for (let candidate = port; candidate < port + 20; candidate += 1) {
|
|
5
|
+
try {
|
|
6
|
+
const server = createLiveViewerServer(runManager);
|
|
7
|
+
await listen(server, candidate);
|
|
8
|
+
return { server, port: candidate, baseUrl: `http://127.0.0.1:${candidate}` };
|
|
9
|
+
}
|
|
10
|
+
catch (error) {
|
|
11
|
+
lastError = error;
|
|
12
|
+
if (!isAddressInUse(error))
|
|
13
|
+
throw error;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
throw lastError instanceof Error ? lastError : new Error(String(lastError));
|
|
17
|
+
}
|
|
18
|
+
function createLiveViewerServer(runManager) {
|
|
19
|
+
const server = createServer((req, res) => {
|
|
20
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
21
|
+
const runMatch = url.pathname.match(/^\/runs\/([^/]+)\/live$/);
|
|
22
|
+
if (runMatch) {
|
|
23
|
+
const run = runManager.getRun(decodeURIComponent(runMatch[1]));
|
|
24
|
+
if (!run) {
|
|
25
|
+
res.statusCode = 404;
|
|
26
|
+
res.end("run not found");
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
30
|
+
res.end(renderLivePage(run.runId));
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const apiMatch = url.pathname.match(/^\/api\/runs\/([^/]+)$/);
|
|
34
|
+
if (apiMatch) {
|
|
35
|
+
const run = runManager.getRun(decodeURIComponent(apiMatch[1]));
|
|
36
|
+
if (!run) {
|
|
37
|
+
res.statusCode = 404;
|
|
38
|
+
res.end(JSON.stringify({ message: "run not found" }));
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
42
|
+
res.end(JSON.stringify(run));
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
res.statusCode = 404;
|
|
46
|
+
res.end("not found");
|
|
47
|
+
});
|
|
48
|
+
return server;
|
|
49
|
+
}
|
|
50
|
+
function listen(server, port) {
|
|
51
|
+
return new Promise((resolve, reject) => {
|
|
52
|
+
const onError = (error) => {
|
|
53
|
+
server.off("listening", onListening);
|
|
54
|
+
reject(error);
|
|
55
|
+
};
|
|
56
|
+
const onListening = () => {
|
|
57
|
+
server.off("error", onError);
|
|
58
|
+
resolve();
|
|
59
|
+
};
|
|
60
|
+
server.once("error", onError);
|
|
61
|
+
server.once("listening", onListening);
|
|
62
|
+
server.listen(port, "127.0.0.1");
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
function isAddressInUse(error) {
|
|
66
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === "EADDRINUSE";
|
|
67
|
+
}
|
|
68
|
+
function renderLivePage(runId) {
|
|
69
|
+
return `<!doctype html>
|
|
70
|
+
<html lang="zh-CN">
|
|
71
|
+
<head>
|
|
72
|
+
<meta charset="utf-8" />
|
|
73
|
+
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
74
|
+
<title>Qingju Self Test ${escapeHtml(runId)}</title>
|
|
75
|
+
<style>
|
|
76
|
+
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #f7f8fa; color: #15171a; }
|
|
77
|
+
header { padding: 16px 20px; background: #101214; color: white; }
|
|
78
|
+
main { display: grid; grid-template-columns: minmax(280px, 420px) 1fr; gap: 16px; padding: 16px; }
|
|
79
|
+
section { background: white; border: 1px solid #dfe3e8; border-radius: 8px; padding: 14px; min-width: 0; }
|
|
80
|
+
h1 { font-size: 16px; margin: 0 0 4px; }
|
|
81
|
+
h2 { font-size: 14px; margin: 0 0 10px; }
|
|
82
|
+
pre { white-space: pre-wrap; overflow-wrap: anywhere; background: #f1f3f5; padding: 10px; border-radius: 6px; max-height: 68vh; overflow: auto; }
|
|
83
|
+
.status { font-weight: 700; }
|
|
84
|
+
.artifact { display: block; margin: 6px 0; overflow-wrap: anywhere; }
|
|
85
|
+
.steps { display: grid; gap: 6px; }
|
|
86
|
+
.step { border: 1px solid #e5e7eb; border-radius: 6px; padding: 8px 10px; background: #fafafa; }
|
|
87
|
+
.step.running { border-color: #2f80ed; background: #eef6ff; }
|
|
88
|
+
.step.passed { border-color: #2f9e44; background: #effaf2; }
|
|
89
|
+
.step.failed { border-color: #d64545; background: #fff1f1; }
|
|
90
|
+
.step-title { font-size: 13px; font-weight: 650; overflow-wrap: anywhere; }
|
|
91
|
+
.step-meta { margin-top: 4px; color: #626b76; font-size: 12px; overflow-wrap: anywhere; }
|
|
92
|
+
@media (max-width: 820px) { main { grid-template-columns: 1fr; } }
|
|
93
|
+
</style>
|
|
94
|
+
</head>
|
|
95
|
+
<body>
|
|
96
|
+
<header>
|
|
97
|
+
<h1>Qingju Live Viewer</h1>
|
|
98
|
+
<div>Run ID: ${escapeHtml(runId)}</div>
|
|
99
|
+
</header>
|
|
100
|
+
<main>
|
|
101
|
+
<section>
|
|
102
|
+
<h2>状态</h2>
|
|
103
|
+
<div id="status" class="status">loading</div>
|
|
104
|
+
<p id="meta"></p>
|
|
105
|
+
<h2>产物</h2>
|
|
106
|
+
<div id="artifacts"></div>
|
|
107
|
+
</section>
|
|
108
|
+
<section>
|
|
109
|
+
<h2>Visual Flow 步骤</h2>
|
|
110
|
+
<div id="steps" class="steps"></div>
|
|
111
|
+
<h2>事件与任务快照</h2>
|
|
112
|
+
<pre id="raw"></pre>
|
|
113
|
+
</section>
|
|
114
|
+
</main>
|
|
115
|
+
<script>
|
|
116
|
+
const runId = ${JSON.stringify(runId)};
|
|
117
|
+
async function refresh() {
|
|
118
|
+
const resp = await fetch('/api/runs/' + encodeURIComponent(runId));
|
|
119
|
+
const run = await resp.json();
|
|
120
|
+
document.getElementById('status').textContent = run.task?.status || 'CREATED';
|
|
121
|
+
document.getElementById('meta').textContent = [run.platform, run.resourceId, run.taskId].filter(Boolean).join(' | ');
|
|
122
|
+
document.getElementById('artifacts').innerHTML = (run.artifacts || []).map(a => {
|
|
123
|
+
const text = a.type + ': ' + a.uri;
|
|
124
|
+
return '<a class="artifact" href="' + escapeAttr(a.uri) + '" target="_blank" rel="noreferrer">' + escapeText(text) + '</a>';
|
|
125
|
+
}).join('') || '暂无产物';
|
|
126
|
+
renderSteps(run.flowStepView);
|
|
127
|
+
document.getElementById('raw').textContent = JSON.stringify(run, null, 2);
|
|
128
|
+
}
|
|
129
|
+
function renderSteps(view) {
|
|
130
|
+
const steps = Array.isArray(view?.steps) ? view.steps : [];
|
|
131
|
+
document.getElementById('steps').innerHTML = steps.map(s => {
|
|
132
|
+
const depth = Number.isFinite(Number(s.depth)) ? Number(s.depth) : 0;
|
|
133
|
+
const meta = [
|
|
134
|
+
'#' + s.index,
|
|
135
|
+
s.type,
|
|
136
|
+
s.status,
|
|
137
|
+
s.durationMs != null ? s.durationMs + 'ms' : '',
|
|
138
|
+
s.message || ''
|
|
139
|
+
].filter(Boolean).join(' | ');
|
|
140
|
+
return '<div class="step ' + escapeAttr(s.status || 'pending') + '" style="margin-left:' + Math.min(depth * 14, 56) + 'px">' +
|
|
141
|
+
'<div class="step-title">' + escapeText(s.title || s.type || 'step') + '</div>' +
|
|
142
|
+
'<div class="step-meta">' + escapeText(meta) + '</div>' +
|
|
143
|
+
'</div>';
|
|
144
|
+
}).join('') || '<div class="step"><div class="step-title">暂无 visualFlow 步骤</div></div>';
|
|
145
|
+
}
|
|
146
|
+
function escapeText(s) { return String(s).replace(/[&<>]/g, c => ({'&':'&','<':'<','>':'>'}[c])); }
|
|
147
|
+
function escapeAttr(s) { return escapeText(s).replace(/"/g, '"'); }
|
|
148
|
+
refresh();
|
|
149
|
+
setInterval(refresh, 1500);
|
|
150
|
+
</script>
|
|
151
|
+
</body>
|
|
152
|
+
</html>`;
|
|
153
|
+
}
|
|
154
|
+
function escapeHtml(value) {
|
|
155
|
+
return value.replace(/[&<>"]/g, (char) => ({ "&": "&", "<": "<", ">": ">", "\"": """ }[char] ?? char));
|
|
156
|
+
}
|