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,260 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { AgentHttpClient } from "./agentHttpClient.js";
|
|
5
|
+
import { AgentRuntimeManager } from "./agentRuntime.js";
|
|
6
|
+
import { runDoctor } from "./doctor.js";
|
|
7
|
+
import { writeEvidence } from "./evidence.js";
|
|
8
|
+
import { startLiveViewer } from "./liveViewer.js";
|
|
9
|
+
import { RunManager } from "./runManager.js";
|
|
10
|
+
import { summarizeRun } from "./runSummary.js";
|
|
11
|
+
import { readReport } from "./reportReader.js";
|
|
12
|
+
import { loadPreflightUserConfig } from "./userConfig.js";
|
|
13
|
+
import { compileVisualFlow, validateVisualFlow } from "./visual-flow/index.js";
|
|
14
|
+
import { registerExplorationTools } from "./exploration/index.js";
|
|
15
|
+
import { createMidsceneSessionFromResourceId, ensureIosWdaStarted } from "./exploration/tools-session.js";
|
|
16
|
+
import { readFile } from "node:fs/promises";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
export function createPreflightMcpServer(options = {}) {
|
|
19
|
+
const agentBaseUrl = options.agentBaseUrl ?? process.env.AGENT_BASE_URL ?? "http://127.0.0.1:18998";
|
|
20
|
+
const livePort = options.livePort ?? Number(process.env.MCP_LIVE_PORT ?? "18999");
|
|
21
|
+
const preflightHome = process.env.PREFLIGHT_HOME?.trim() || `${homedir()}/.preflight`;
|
|
22
|
+
const projectRoot = options.runtimeRoot ?? process.env.AGENT_RUNTIME_ROOT?.trim() ?? options.projectRoot ?? process.env.PROJECT_ROOT ?? process.cwd();
|
|
23
|
+
const client = new AgentHttpClient({ baseUrl: agentBaseUrl, token: options.agentToken ?? process.env.AGENT_HTTP_TOKEN });
|
|
24
|
+
const loadConfigEnv = async () => (await loadPreflightUserConfig()).env;
|
|
25
|
+
const runtime = new AgentRuntimeManager({ projectRoot, agentBaseUrl, client, env: process.env, runtimeRoot: options.runtimeRoot, loadConfigEnv });
|
|
26
|
+
const liveBaseUrl = `http://127.0.0.1:${livePort}`;
|
|
27
|
+
const runManager = new RunManager(client, liveBaseUrl);
|
|
28
|
+
let liveServerStarted;
|
|
29
|
+
const server = new McpServer({ name: "Preflight", version: "0.1.0" });
|
|
30
|
+
server.registerTool("agent_health", {
|
|
31
|
+
title: "Agent Health",
|
|
32
|
+
description: "Check whether the local automation-agent HTTP service is reachable.",
|
|
33
|
+
}, async () => jsonResult(await runtime.status()));
|
|
34
|
+
server.registerTool("start_agent", {
|
|
35
|
+
title: "Start Agent",
|
|
36
|
+
description: "Start or reuse the local automation-agent runtime required by Preflight MCP tools.",
|
|
37
|
+
}, async () => jsonResult(await runtime.ensureStarted()));
|
|
38
|
+
server.registerTool("stop_agent", {
|
|
39
|
+
title: "Stop Agent",
|
|
40
|
+
description: "Stop the automation-agent process started by this MCP server. Does not kill manually-started agents.",
|
|
41
|
+
}, async () => jsonResult(await runtime.stop()));
|
|
42
|
+
server.registerTool("start_ios_wda", {
|
|
43
|
+
title: "Start iOS WebDriverAgent",
|
|
44
|
+
description: "Start or ensure WebDriverAgent (WDA) is running on the specified iOS device. " +
|
|
45
|
+
"Required before exploration_start for iOS devices. Call this when doctor reports iOS WebDriverAgent as not running.",
|
|
46
|
+
inputSchema: {
|
|
47
|
+
resourceId: z.string().describe("iOS device resource ID (e.g., ios:xxx-xxx-xxx)"),
|
|
48
|
+
},
|
|
49
|
+
}, async ({ resourceId }) => {
|
|
50
|
+
await runtime.ensureStarted();
|
|
51
|
+
const configEnv = await loadConfigEnv();
|
|
52
|
+
const runtimeEnv = { ...configEnv };
|
|
53
|
+
return jsonResult(await ensureIosWdaStarted(resourceId, runtimeEnv, projectRoot));
|
|
54
|
+
});
|
|
55
|
+
server.registerTool("config_status", {
|
|
56
|
+
title: "Config Status",
|
|
57
|
+
description: "Show which Preflight user config file is loaded without exposing secret values.",
|
|
58
|
+
}, async () => {
|
|
59
|
+
const config = await loadPreflightUserConfig();
|
|
60
|
+
return jsonResult({
|
|
61
|
+
path: config.path ?? null,
|
|
62
|
+
keys: Object.keys(config.env).sort(),
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
server.registerTool("get_visual_flow_ir_rules", {
|
|
66
|
+
title: "Get Visual Flow IR Rules",
|
|
67
|
+
description: "Return the low-noise Visual Flow IR rules that the model should follow when generating test cases.",
|
|
68
|
+
}, async () => {
|
|
69
|
+
const text = await readFile(join(projectRoot, "docs", "visual-flow-ir-llm.md"), "utf8").catch(() => "");
|
|
70
|
+
return { content: [{ type: "text", text: `${VISUAL_FLOW_LLM_HARD_RULES}\n\n${text}` }] };
|
|
71
|
+
});
|
|
72
|
+
server.registerTool("validate_visual_flow", {
|
|
73
|
+
title: "Validate Visual Flow",
|
|
74
|
+
description: "Validate a generated visualFlow JSON before compiling or running it.",
|
|
75
|
+
inputSchema: {
|
|
76
|
+
visualFlow: z.record(z.unknown()),
|
|
77
|
+
},
|
|
78
|
+
}, async ({ visualFlow }) => jsonResult(validateVisualFlow(visualFlow)));
|
|
79
|
+
server.registerTool("doctor", {
|
|
80
|
+
title: "Doctor",
|
|
81
|
+
description: "Auto-start the local automation-agent, then check blocking dependencies (Midscene API key, adb, hdc, Xcode/xcrun, iproxy) and iOS WebDriverAgent health.",
|
|
82
|
+
}, async () => jsonResult(await runDoctor({
|
|
83
|
+
env: { ...process.env, ...(await loadConfigEnv()) },
|
|
84
|
+
agentHealth: async () => (await runtime.ensureStarted()).health ?? client.health(),
|
|
85
|
+
})));
|
|
86
|
+
server.registerTool("list_devices", {
|
|
87
|
+
title: "List Devices",
|
|
88
|
+
description: "List devices currently visible to the local automation-agent.",
|
|
89
|
+
}, async () => {
|
|
90
|
+
await runtime.ensureStarted();
|
|
91
|
+
return jsonResult(await client.listDevices());
|
|
92
|
+
});
|
|
93
|
+
server.registerTool("install_app", {
|
|
94
|
+
title: "Install App",
|
|
95
|
+
description: "Install an app package on a selected local device through automation-agent.",
|
|
96
|
+
inputSchema: {
|
|
97
|
+
resourceId: z.string().describe("Device resource id, for example android:serial or ios:udid."),
|
|
98
|
+
appRef: z.string().describe("Local path, file:// URL, or http(s) URL of the app package."),
|
|
99
|
+
},
|
|
100
|
+
}, async ({ resourceId, appRef }) => {
|
|
101
|
+
await runtime.ensureStarted();
|
|
102
|
+
return jsonResult(await client.installApp(resourceId, appRef));
|
|
103
|
+
});
|
|
104
|
+
server.registerTool("run_flow", {
|
|
105
|
+
title: "Run Visual Flow Test",
|
|
106
|
+
description: "Validate and compile a visualFlow JSON, then run it through the local automation-agent with live viewer support. " +
|
|
107
|
+
"IMPORTANT: MCP transport has a 60-second timeout. For multi-step flows (3+ steps), the test WILL NOT finish within this timeout. " +
|
|
108
|
+
"Strategy for long-running tests: " +
|
|
109
|
+
"(1) Set waitForCompletion to false — the tool returns immediately with a runId. " +
|
|
110
|
+
"(2) Then poll with watch_run (without waitForCompletion) until status shows completed/failed. " +
|
|
111
|
+
"Only set waitForCompletion: true for very short flows (1-2 steps) where total time is under 60s.",
|
|
112
|
+
inputSchema: {
|
|
113
|
+
platform: z.enum(["ANDROID", "IOS", "HARMONY"]),
|
|
114
|
+
visualFlow: z.record(z.unknown()),
|
|
115
|
+
resourceId: z.string().optional(),
|
|
116
|
+
appRef: z.string().optional(),
|
|
117
|
+
testIntent: z.string().optional(),
|
|
118
|
+
runtimeEnv: z.record(z.string()).optional(),
|
|
119
|
+
waitForCompletion: z.boolean().optional().describe("Set to false for multi-step flows (3+ steps) — returns immediately with a runId, then poll with watch_run. " +
|
|
120
|
+
"Set to true only for very short flows (1-2 steps) that finish within the MCP 60s transport timeout."),
|
|
121
|
+
timeoutMs: z.number().int().positive().optional().describe("Maximum wait time in milliseconds for the test to complete. " +
|
|
122
|
+
"Calculate as: number of visual flow steps × 60000 (1 minute per step). " +
|
|
123
|
+
"For example, a 5-step flow should set timeoutMs to 300000. " +
|
|
124
|
+
"Default is 120000 (2 minutes) which is only suitable for very short flows (1-2 steps). " +
|
|
125
|
+
"NOTE: Only effective when waitForCompletion is true. " +
|
|
126
|
+
"MCP transport has a 60s hard timeout — if timeoutMs exceeds 60s, " +
|
|
127
|
+
"use waitForCompletion: false and poll with watch_run instead."),
|
|
128
|
+
},
|
|
129
|
+
}, async (input) => {
|
|
130
|
+
await runtime.ensureStarted();
|
|
131
|
+
const parsed = validateVisualFlow(input.visualFlow);
|
|
132
|
+
if (!parsed.ok)
|
|
133
|
+
return jsonResult(parsed);
|
|
134
|
+
liveServerStarted ??= startLiveViewer(livePort, runManager).then((viewer) => {
|
|
135
|
+
runManager.setLiveBaseUrl(viewer.baseUrl);
|
|
136
|
+
});
|
|
137
|
+
await liveServerStarted;
|
|
138
|
+
const script = await compileVisualFlow(parsed.value);
|
|
139
|
+
const started = await runManager.startRun({
|
|
140
|
+
platform: input.platform,
|
|
141
|
+
script,
|
|
142
|
+
scriptKind: "midscene",
|
|
143
|
+
resourceId: input.resourceId,
|
|
144
|
+
appRef: input.appRef,
|
|
145
|
+
testIntent: input.testIntent,
|
|
146
|
+
runtimeEnv: { ...preflightRunDefaults(), ...(await loadConfigEnv()), ...input.runtimeEnv },
|
|
147
|
+
visualFlow: parsed.value,
|
|
148
|
+
});
|
|
149
|
+
if (!input.waitForCompletion)
|
|
150
|
+
return jsonResult({ ...started, visualFlow: parsed.value });
|
|
151
|
+
return jsonResult(await runManager.waitForRun(started.runId, input.timeoutMs ?? 120_000, 2_000));
|
|
152
|
+
});
|
|
153
|
+
server.registerTool("watch_run", {
|
|
154
|
+
title: "Watch Test Run",
|
|
155
|
+
description: "Refresh and summarize a test run. Use while the live viewer is open. " +
|
|
156
|
+
"For long-running tests that exceed the MCP transport timeout (60s): " +
|
|
157
|
+
"(1) Call run_flow with waitForCompletion: false to get a runId. " +
|
|
158
|
+
"(2) Call watch_run with minIntervalMs (e.g. 30000 = check every 30s) to poll. " +
|
|
159
|
+
"minIntervalMs blocks on the server side and polls the agent every 2s internally, " +
|
|
160
|
+
"so one call covers one full interval without consuming tokens on intermediate checks. " +
|
|
161
|
+
"Do NOT call run_flow again — that creates a duplicate run.",
|
|
162
|
+
inputSchema: {
|
|
163
|
+
runId: z.string(),
|
|
164
|
+
waitForCompletion: z.boolean().optional().describe("Set to true to block until the run finishes (respects timeoutMs). " +
|
|
165
|
+
"Omit or set to false for a lightweight status check — use this for polling."),
|
|
166
|
+
timeoutMs: z.number().int().positive().optional().describe("Maximum wait time in milliseconds. " +
|
|
167
|
+
"Based on the total number of steps in the original visual flow: steps × 60000. " +
|
|
168
|
+
"Default is 120000 (2 minutes). " +
|
|
169
|
+
"NOTE: Only effective when waitForCompletion is true. " +
|
|
170
|
+
"Due to MCP 60s transport timeout, prefer polling without waitForCompletion for long runs."),
|
|
171
|
+
minIntervalMs: z.number().int().positive().optional().describe("Block for at least this many milliseconds before returning. " +
|
|
172
|
+
"While blocked, the server polls the agent every 2s internally. " +
|
|
173
|
+
"If the run finishes early, returns immediately. " +
|
|
174
|
+
"Use 30000 for a ~30s polling cadence without consuming extra tokens " +
|
|
175
|
+
"on intermediate calls. Capped internally to 55000 to leave headroom " +
|
|
176
|
+
"for the 60s MCP transport timeout."),
|
|
177
|
+
},
|
|
178
|
+
}, async ({ runId, waitForCompletion, timeoutMs, minIntervalMs }) => {
|
|
179
|
+
await runtime.ensureStarted();
|
|
180
|
+
return jsonResult(waitForCompletion ? await runManager.waitForRun(runId, timeoutMs ?? 120_000, 2_000) : await runManager.watchRun(runId, minIntervalMs));
|
|
181
|
+
});
|
|
182
|
+
server.registerTool("cancel_run", {
|
|
183
|
+
title: "Cancel Test Run",
|
|
184
|
+
description: "Cancel a running test. Use this when the test is taking too long or you want to stop it early. " +
|
|
185
|
+
"After cancellation, poll watch_run to confirm the status becomes CANCELLED.",
|
|
186
|
+
inputSchema: {
|
|
187
|
+
runId: z.string(),
|
|
188
|
+
reason: z.string().optional().describe("Why the run was cancelled (e.g. 'test took too long', 'debug info collected')"),
|
|
189
|
+
},
|
|
190
|
+
}, async ({ runId, reason }) => {
|
|
191
|
+
await runtime.ensureStarted();
|
|
192
|
+
return jsonResult(await runManager.cancelRun(runId, "model", reason ?? "no reason given"));
|
|
193
|
+
});
|
|
194
|
+
server.registerTool("save_report", {
|
|
195
|
+
title: "Save Test Report",
|
|
196
|
+
description: "Save the test report for a completed or failed test run.",
|
|
197
|
+
inputSchema: {
|
|
198
|
+
runId: z.string(),
|
|
199
|
+
},
|
|
200
|
+
}, async ({ runId }) => {
|
|
201
|
+
await runtime.ensureStarted();
|
|
202
|
+
await runManager.watchRun(runId);
|
|
203
|
+
const run = runManager.getRun(runId);
|
|
204
|
+
if (!run)
|
|
205
|
+
throw new Error(`Unknown runId: ${runId}`);
|
|
206
|
+
const summary = summarizeRun(run);
|
|
207
|
+
return jsonResult(await writeEvidence({
|
|
208
|
+
outputRoot: preflightHome,
|
|
209
|
+
run: {
|
|
210
|
+
...run,
|
|
211
|
+
status: summary.status,
|
|
212
|
+
failureAnalysis: summary.failureAnalysis,
|
|
213
|
+
},
|
|
214
|
+
}));
|
|
215
|
+
});
|
|
216
|
+
server.registerTool("read_report", {
|
|
217
|
+
title: "Read Test Report",
|
|
218
|
+
description: "Read a Midscene test run report directory and extract the operation path (what actually happened during the test). " +
|
|
219
|
+
"The reportDir is the path to the report directory containing N.execution.json files, " +
|
|
220
|
+
"typically found under ~/.preflight/midscene_run/report/. " +
|
|
221
|
+
"Returns the operation path with step-by-step details that the model can summarize.",
|
|
222
|
+
inputSchema: {
|
|
223
|
+
reportDir: z.string().describe("Absolute path to the Midscene report directory containing *.execution.json files"),
|
|
224
|
+
},
|
|
225
|
+
}, async ({ reportDir }) => {
|
|
226
|
+
const result = await readReport(reportDir);
|
|
227
|
+
return jsonResult(result);
|
|
228
|
+
});
|
|
229
|
+
registerExplorationTools(server, { client, loadConfigEnv, ensureAgentStarted: async () => { await runtime.ensureStarted(); }, createSessionFromMeta: createMidsceneSessionFromResourceId, projectRoot });
|
|
230
|
+
return server;
|
|
231
|
+
}
|
|
232
|
+
function jsonResult(value) {
|
|
233
|
+
return {
|
|
234
|
+
content: [{ type: "text", text: JSON.stringify(value, null, 2) }],
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
function preflightRunDefaults() {
|
|
238
|
+
return {
|
|
239
|
+
MIDSCENE_OUTPUT_FORMAT: "html-and-external-assets",
|
|
240
|
+
MIDSCENE_RECORD_VIDEO_ENABLED: "1",
|
|
241
|
+
MIDSCENE_RECORD_VIDEO_PLAYBACK_RATE: "2",
|
|
242
|
+
MIDSCENE_RECORD_VIDEO_SCALE_WIDTH: "540",
|
|
243
|
+
MIDSCENE_RECORD_VIDEO_CRF: "32",
|
|
244
|
+
MIDSCENE_RECORD_VIDEO_PRESET: "fast",
|
|
245
|
+
MIDSCENE_RUN_TIMEOUT_MS: "1200000", // 20分钟,适配多步骤流程
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
const VISUAL_FLOW_LLM_HARD_RULES = `# Preflight Visual Flow IR hard rules
|
|
249
|
+
|
|
250
|
+
- Use visualFlow JSON as the output format.
|
|
251
|
+
- Variables declared by scriptVars, setVar, assignVar, or transformVar must be referenced only with interpolation syntax: {{varName}}, {{varName[0]}}, {{varName.1}}, or {{varName.length}}.
|
|
252
|
+
- In prompt/value/expression fields, write declared variables with interpolation syntax. Example: "{{timeBefore}}和{{timeAfter}}不同".
|
|
253
|
+
- If a value comes from a previous step, create it with setVar/assignVar/transformVar first, then reference it with {{}} in later steps.
|
|
254
|
+
- Use aiAct for complex multi-step interactions that need visual planning. Describe the user goal and important constraints, then let the visual model plan the concrete taps/swipes/inputs.
|
|
255
|
+
- Use setAIActContext to define cross-step handling for unexpected UI, for example "遇到权限弹窗请同意,营销弹窗请拒绝". This context is carried into later act operations, so the visual model can handle temporary popups as part of the normal action flow.
|
|
256
|
+
- Use fixed sleep steps for page transitions, app launch, refresh, animations, and list updates, for example {"type":"sleep","ms":3000}.
|
|
257
|
+
- Place assert steps only at critical verification points, usually the changed behavior or necessary regression checkpoint. Normal action failures already stop the run.
|
|
258
|
+
- Make every step prompt self-contained. Each step is executed as an independent visual instruction, so include the necessary target, expected state, or comparison data inside that step.
|
|
259
|
+
- Keep prompts concise and explicit. Provide the required information only; the visual model plans detailed interaction from clear short instructions.
|
|
260
|
+
`;
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { installLocalRuntime } from "./runtimeInstall.js";
|
|
5
|
+
export async function setupLocalMcp(options) {
|
|
6
|
+
const agentBaseUrl = options.agentBaseUrl ?? "http://127.0.0.1:18998";
|
|
7
|
+
const livePort = options.livePort ?? 18999;
|
|
8
|
+
const shouldInstallRuntime = options.installRuntime ?? true;
|
|
9
|
+
const installedRuntime = shouldInstallRuntime
|
|
10
|
+
? await installLocalRuntime({ projectRoot: options.projectRoot, runtimeRoot: options.runtimeRoot })
|
|
11
|
+
: undefined;
|
|
12
|
+
const runtimeRoot = installedRuntime?.runtimeRoot ?? options.runtimeRoot ?? process.env.AGENT_RUNTIME_ROOT?.trim();
|
|
13
|
+
const isRuntime = !!runtimeRoot;
|
|
14
|
+
const cursorConfigPath = join(options.projectRoot, ".cursor", "mcp.json");
|
|
15
|
+
const cursorRulePath = join(options.projectRoot, ".cursor", "rules", "preflight.mdc");
|
|
16
|
+
const skillPath = join(options.projectRoot, ".preflight", "skills", "preflight.md");
|
|
17
|
+
const codexSkillPath = join(homedir(), ".codex", "skills", "preflight", "SKILL.md");
|
|
18
|
+
const agentsSkillPath = join(homedir(), ".agents", "skills", "preflight", "SKILL.md");
|
|
19
|
+
const userConfigExamplePath = join(homedir(), ".preflight", "config.example.json");
|
|
20
|
+
const codexConfigPath = join(homedir(), ".codex", "config.toml");
|
|
21
|
+
await writeCursorMcpConfig(cursorConfigPath, options.projectRoot, agentBaseUrl, livePort, isRuntime, runtimeRoot);
|
|
22
|
+
await writeTextFile(cursorRulePath, cursorRuleText());
|
|
23
|
+
await writeTextFile(skillPath, skillText());
|
|
24
|
+
await writeTextFile(codexSkillPath, skillText());
|
|
25
|
+
await writeTextFile(agentsSkillPath, skillText());
|
|
26
|
+
await writeTextFile(userConfigExamplePath, userConfigExampleText());
|
|
27
|
+
await upsertCodexMcpConfig(codexConfigPath, options.projectRoot, agentBaseUrl, livePort, isRuntime, runtimeRoot);
|
|
28
|
+
return { cursorConfigPath, cursorRulePath, codexConfigPath, skillPath, codexSkillPath, agentsSkillPath, runtimeRoot, userConfigExamplePath };
|
|
29
|
+
}
|
|
30
|
+
async function writeCursorMcpConfig(path, projectRoot, agentBaseUrl, livePort, isRuntime, runtimeRoot) {
|
|
31
|
+
let existing = {};
|
|
32
|
+
try {
|
|
33
|
+
existing = JSON.parse(await readFile(path, "utf8"));
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
existing = {};
|
|
37
|
+
}
|
|
38
|
+
const mcpServers = typeof existing.mcpServers === "object" && existing.mcpServers
|
|
39
|
+
? { ...existing.mcpServers }
|
|
40
|
+
: {};
|
|
41
|
+
delete mcpServers["Preflight"];
|
|
42
|
+
if (isRuntime && runtimeRoot) {
|
|
43
|
+
const nodeBin = join(runtimeRoot, "node", "bin", "node");
|
|
44
|
+
const mcpEntry = join(runtimeRoot, "dist", "mcp", "cli.js");
|
|
45
|
+
existing.mcpServers = {
|
|
46
|
+
...mcpServers,
|
|
47
|
+
"Preflight": {
|
|
48
|
+
command: nodeBin,
|
|
49
|
+
args: [mcpEntry, "serve"],
|
|
50
|
+
env: {
|
|
51
|
+
AGENT_RUNTIME_ROOT: runtimeRoot,
|
|
52
|
+
AGENT_BASE_URL: agentBaseUrl,
|
|
53
|
+
MCP_LIVE_PORT: String(livePort),
|
|
54
|
+
PREFLIGHT_HOME: join(homedir(), ".preflight"),
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
existing.mcpServers = {
|
|
61
|
+
...mcpServers,
|
|
62
|
+
"Preflight": {
|
|
63
|
+
command: "npm",
|
|
64
|
+
args: ["--silent", "--prefix", projectRoot, "run", "mcp", "--", "serve"],
|
|
65
|
+
env: {
|
|
66
|
+
PROJECT_ROOT: projectRoot,
|
|
67
|
+
AGENT_BASE_URL: agentBaseUrl,
|
|
68
|
+
MCP_LIVE_PORT: String(livePort),
|
|
69
|
+
PREFLIGHT_HOME: join(homedir(), ".preflight"),
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
await writeTextFile(path, `${JSON.stringify(existing, null, 2)}\n`);
|
|
75
|
+
}
|
|
76
|
+
async function upsertCodexMcpConfig(path, projectRoot, agentBaseUrl, livePort, isRuntime, runtimeRoot) {
|
|
77
|
+
let existing = "";
|
|
78
|
+
try {
|
|
79
|
+
existing = await readFile(path, "utf8");
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
existing = "";
|
|
83
|
+
}
|
|
84
|
+
await writeTextFile(path, mergeCodexMcpConfig(existing, projectRoot, agentBaseUrl, livePort, isRuntime, runtimeRoot));
|
|
85
|
+
}
|
|
86
|
+
export function mergeCodexMcpConfig(existing, projectRoot, agentBaseUrl, livePort, isRuntime = false, runtimeRoot) {
|
|
87
|
+
const block = isRuntime && runtimeRoot
|
|
88
|
+
? `[mcp_servers.Preflight]
|
|
89
|
+
command = ${tomlString(join(runtimeRoot, "node", "bin", "node"))}
|
|
90
|
+
args = [${tomlString(join(runtimeRoot, "dist", "mcp", "cli.js"))}, "serve"]
|
|
91
|
+
env = { AGENT_RUNTIME_ROOT = ${tomlString(runtimeRoot)}, AGENT_BASE_URL = ${tomlString(agentBaseUrl)}, MCP_LIVE_PORT = ${tomlString(String(livePort))}, PREFLIGHT_HOME = ${tomlString(join(homedir(), ".preflight"))} }
|
|
92
|
+
`
|
|
93
|
+
: `[mcp_servers.Preflight]
|
|
94
|
+
command = "npm"
|
|
95
|
+
args = ["--silent", "--prefix", ${tomlString(projectRoot)}, "run", "mcp", "--", "serve"]
|
|
96
|
+
env = { PROJECT_ROOT = ${tomlString(projectRoot)}, AGENT_BASE_URL = ${tomlString(agentBaseUrl)}, MCP_LIVE_PORT = ${tomlString(String(livePort))}, PREFLIGHT_HOME = ${tomlString(join(homedir(), ".preflight"))} }
|
|
97
|
+
`;
|
|
98
|
+
const withoutOld = existing
|
|
99
|
+
.replace(/(?:^|\n)\[mcp_servers\.Preflight\]\n(?:(?!\[).*(?:\n|$))*/g, "\n")
|
|
100
|
+
.trimEnd();
|
|
101
|
+
return `${withoutOld}${withoutOld ? "\n\n" : ""}${block}`;
|
|
102
|
+
}
|
|
103
|
+
async function writeTextFile(path, text) {
|
|
104
|
+
await mkdir(dirname(path), { recursive: true });
|
|
105
|
+
await writeFile(path, text, "utf8");
|
|
106
|
+
}
|
|
107
|
+
function tomlString(value) {
|
|
108
|
+
return JSON.stringify(value);
|
|
109
|
+
}
|
|
110
|
+
function cursorRuleText() {
|
|
111
|
+
return `---
|
|
112
|
+
description: 使用本机 Preflight MCP 做移动端测试
|
|
113
|
+
alwaysApply: false
|
|
114
|
+
---
|
|
115
|
+
|
|
116
|
+
当用户要求"测试""跑一下这次的改动"时:
|
|
117
|
+
|
|
118
|
+
0. 必须先确认 Preflight MCP 工具已经注入当前会话。若看不到 \`doctor\`、\`list_devices\` 等工具,立即停止并提示用户重启 Codex/Cursor 或检查 MCP 配置;不要改用 shell、curl、npm script、读取本仓库源码等方式模拟 MCP。
|
|
119
|
+
1. 只在用户当前项目中读取 git diff,总结本次改动影响的页面、流程、平台和风险点。
|
|
120
|
+
2. 调用 \`start_agent\` 或 \`doctor\`;MCP 会自动拉起本地 automation-agent,不需要用户手动执行 \`npm run dev\`,也不需要 test-service/platform。
|
|
121
|
+
3. 如果 Midscene API key 这类阻塞项失败,先把阻塞项说清楚,不要继续跑;如果 ffmpeg 或 scrcpy 缺失,说明本次不会生成 Android 操作录屏;\`AGENT_HTTP_TOKEN\` 不是本地 MCP 必填项。
|
|
122
|
+
4. 如需确认配置来源,调用 \`config_status\`;模型 API key 应放在 \`~/.preflight/config.json\` 或 \`~/.preflight/config.yaml\`,不要写进 MCP 配置环境变量。
|
|
123
|
+
5. 调用 \`list_devices\`,优先选择与改动相关的平台设备。
|
|
124
|
+
6. 调用 \`get_visual_flow_ir_rules\`,按 IR 规范生成 visualFlow JSON。默认不要直接写 Midscene TS 脚本。
|
|
125
|
+
7. 调用 \`validate_visual_flow\`。如果校验失败,按 message 修正 visualFlow 后再次校验。
|
|
126
|
+
8. 生成最小测试用例:先覆盖改动点,再补必要回归。
|
|
127
|
+
9. 如用户给了 app 包,先调用 \`install_app\`。
|
|
128
|
+
10. 调用 \`run_flow\`,把返回的 liveUrl 明确给用户,让用户可以打开浏览器看实时执行。
|
|
129
|
+
11. 执行中调用 \`watch_run\` 观察状态。失败时先判断是环境/设备、IR 用例步骤、agent runtime 还是真实业务问题。
|
|
130
|
+
12. 如果失败原因是 IR 步骤不合理,只能调整 visualFlow 后重跑;不要读取或手写 Midscene TS 脚本。若是 Preflight 编译器/runtime 内部错误,停止并报告为工具缺陷。
|
|
131
|
+
13. 最终调用 \`save_report\`,并在回复中给出测试报告、report/liveUrl 和 PASS/FAIL 结论。
|
|
132
|
+
`;
|
|
133
|
+
}
|
|
134
|
+
function skillText() {
|
|
135
|
+
return `---
|
|
136
|
+
name: preflight
|
|
137
|
+
description: Use when the user wants to test mobile app changes, run visualFlow IR cases, inspect device/runtime blockers, or debug failures with live viewer output.
|
|
138
|
+
---
|
|
139
|
+
|
|
140
|
+
# Preflight
|
|
141
|
+
|
|
142
|
+
Use the local Preflight MCP server to run mobile tests via visualFlow IR. The default path is visualFlow IR, not raw Midscene TypeScript.
|
|
143
|
+
|
|
144
|
+
Hard gate:
|
|
145
|
+
|
|
146
|
+
- This skill requires injected MCP tools named \`start_agent\`, \`doctor\`, \`config_status\`, \`list_devices\`, \`get_visual_flow_ir_rules\`, \`validate_visual_flow\`, \`run_flow\`, \`watch_run\`, and \`save_report\`.
|
|
147
|
+
- If those tools are unavailable in the current conversation, stop immediately and tell the user to restart Codex/Cursor or check the MCP config. Do not use shell commands, curl, npm scripts, or direct reads of this repository to imitate the MCP tools.
|
|
148
|
+
- When running from another project, inspect only that project's diff and files. Treat this repository as an implementation detail behind the MCP server.
|
|
149
|
+
- Do not ask the user to start test-service/platform for local testing. The MCP server auto-starts automation-agent in local MCP mode; the only universal required secret is a Midscene-compatible model API key.
|
|
150
|
+
- Preflight runs write report assets under \`~/.preflight/midscene_run/report/<reportName>/\`, including \`index.html\`, execution JSON, screenshots, and compressed recordings when ffmpeg and the platform recorder are available. Android recording uses scrcpy.
|
|
151
|
+
- Read Midscene model configuration from \`~/.preflight/config.json\`, \`~/.preflight/config.yaml\`, or \`~/.preflight/config.yml\`. Do not ask the user to put API keys in Codex/Cursor MCP environment variables.
|
|
152
|
+
- Do not write, inspect, or repair raw Midscene TypeScript scripts. If a run fails because the generated script cannot compile, go back to \`get_visual_flow_ir_rules\` + \`validate_visual_flow\` and adjust the visualFlow JSON. If the visualFlow validates but Preflight still compiles bad TypeScript, report a Preflight bug instead of reading this repository.
|
|
153
|
+
|
|
154
|
+
Workflow:
|
|
155
|
+
|
|
156
|
+
1. Inspect git diff and identify affected flows.
|
|
157
|
+
2. Start or reuse the local runtime with \`start_agent\`; \`doctor\` also auto-starts it.
|
|
158
|
+
3. Run \`doctor\`; stop on blocking failures such as missing model API key.
|
|
159
|
+
4. If config is unclear, call \`config_status\`; it reports loaded config path and key names without exposing values.
|
|
160
|
+
5. List devices with \`list_devices\`.
|
|
161
|
+
6. Read \`get_visual_flow_ir_rules\` and generate visualFlow JSON, not raw Midscene TypeScript.
|
|
162
|
+
7. Validate with \`validate_visual_flow\`; fix the JSON until validation passes.
|
|
163
|
+
8. Install the app when an app package path is provided.
|
|
164
|
+
9. Start the run with \`run_flow\` and show the returned liveUrl.
|
|
165
|
+
10. Poll with \`watch_run\`.
|
|
166
|
+
11. Analyze failures before retrying. Distinguish device/env failures, brittle IR steps, agent runtime failures, and real app bugs. For IR problems, revise visualFlow only; never switch to raw Midscene script repair.
|
|
167
|
+
12. Save report with \`save_report\`.
|
|
168
|
+
|
|
169
|
+
Reports are written under \`~/.preflight/midscene_run/report/<reportName>/\`.
|
|
170
|
+
|
|
171
|
+
If Preflight MCP tools are missing, tell the user to restart Codex after checking \`~/.codex/config.toml\` contains \`[mcp_servers.Preflight]\`. Do not continue with a fallback path.
|
|
172
|
+
`;
|
|
173
|
+
}
|
|
174
|
+
function userConfigExampleText() {
|
|
175
|
+
return `{
|
|
176
|
+
"env": {
|
|
177
|
+
"MIDSCENE_MODEL_BASE_URL": "https://ark.cn-beijing.volces.com/api/v3",
|
|
178
|
+
"MIDSCENE_MODEL_API_KEY": "replace-me",
|
|
179
|
+
"MIDSCENE_MODEL_NAME": "doubao-seed-2-0-lite-260215",
|
|
180
|
+
"MIDSCENE_MODEL_FAMILY": "doubao-seed",
|
|
181
|
+
"MIDSCENE_MODEL_REASONING_ENABLED": "false"
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
`;
|
|
185
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { parse as parseYaml } from "yaml";
|
|
5
|
+
const CONFIG_PATHS = [
|
|
6
|
+
join(homedir(), ".preflight", "config.json"),
|
|
7
|
+
join(homedir(), ".preflight", "config.yaml"),
|
|
8
|
+
join(homedir(), ".preflight", "config.yml"),
|
|
9
|
+
];
|
|
10
|
+
export async function loadPreflightUserConfig() {
|
|
11
|
+
for (const path of CONFIG_PATHS) {
|
|
12
|
+
const raw = await readFile(path, "utf8").catch(() => undefined);
|
|
13
|
+
if (raw === undefined)
|
|
14
|
+
continue;
|
|
15
|
+
const parsed = path.endsWith(".json") ? JSON.parse(raw) : parseYaml(raw);
|
|
16
|
+
return { path, env: flattenConfig(parsed) };
|
|
17
|
+
}
|
|
18
|
+
return { env: {} };
|
|
19
|
+
}
|
|
20
|
+
function flattenConfig(value) {
|
|
21
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
22
|
+
return {};
|
|
23
|
+
const root = value;
|
|
24
|
+
const envCandidate = root.env && typeof root.env === "object" && !Array.isArray(root.env) ? root.env : root;
|
|
25
|
+
const out = {};
|
|
26
|
+
for (const [rawKey, rawValue] of Object.entries(envCandidate)) {
|
|
27
|
+
const key = rawKey.trim();
|
|
28
|
+
if (!/^[A-Z][A-Z0-9_]*$/.test(key))
|
|
29
|
+
continue;
|
|
30
|
+
const text = scalarToString(rawValue);
|
|
31
|
+
if (text === undefined || !text.trim())
|
|
32
|
+
continue;
|
|
33
|
+
out[key] = text;
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
function scalarToString(value) {
|
|
38
|
+
if (typeof value === "string")
|
|
39
|
+
return value;
|
|
40
|
+
if (typeof value === "number" && Number.isFinite(value))
|
|
41
|
+
return String(value);
|
|
42
|
+
if (typeof value === "boolean")
|
|
43
|
+
return value ? "true" : "false";
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|