preflite 1.0.1 → 1.1.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/dist/mcp/cli.js CHANGED
@@ -1,5 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { access } from "node:fs/promises";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
3
6
  import { createPreflightMcpServer } from "./server.js";
4
7
  import { setupLocalMcp } from "./setup.js";
5
8
  // Prevent unhandled rejections / exceptions from crashing the server mid-session
@@ -17,11 +20,12 @@ if (command === "serve") {
17
20
  }
18
21
  else if (command === "setup") {
19
22
  const projectRoot = argValue("--project-root") ?? process.cwd();
23
+ const runtimeSourceRoot = argValue("--runtime-source-root") ?? await findPackageRoot();
20
24
  const agentBaseUrl = argValue("--agent-base-url") ?? process.env.AGENT_BASE_URL ?? "http://127.0.0.1:18998";
21
25
  const livePort = Number(argValue("--live-port") ?? process.env.MCP_LIVE_PORT ?? "18999");
22
26
  const runtimeRoot = argValue("--runtime-root") ?? process.env.AGENT_RUNTIME_ROOT?.trim() ?? undefined;
23
27
  const installRuntime = !process.argv.includes("--no-install-runtime");
24
- const result = await setupLocalMcp({ projectRoot, agentBaseUrl, livePort, runtimeRoot, installRuntime });
28
+ const result = await setupLocalMcp({ projectRoot, runtimeSourceRoot, agentBaseUrl, livePort, runtimeRoot, installRuntime });
25
29
  console.log(JSON.stringify({ ok: true, ...result }, null, 2));
26
30
  }
27
31
  else {
@@ -34,3 +38,25 @@ function argValue(name) {
34
38
  return undefined;
35
39
  return process.argv[idx + 1];
36
40
  }
41
+ async function findPackageRoot() {
42
+ let current = dirname(fileURLToPath(import.meta.url));
43
+ while (true) {
44
+ if (await exists(join(current, "package.json"))) {
45
+ return current;
46
+ }
47
+ const parent = dirname(current);
48
+ if (parent === current) {
49
+ throw new Error("Could not locate the preflite package root.");
50
+ }
51
+ current = parent;
52
+ }
53
+ }
54
+ async function exists(path) {
55
+ try {
56
+ await access(path);
57
+ return true;
58
+ }
59
+ catch {
60
+ return false;
61
+ }
62
+ }
@@ -53,7 +53,7 @@ export function registerExplorationTools(server, ctx) {
53
53
  // ---------------------------------------------------------------------------
54
54
  server.registerTool("exploration_get_page_summary", {
55
55
  title: "Get Page Summary",
56
- description: "Get a natural language summary of the current screen, including layout type (fixed single-screen / scrollable long page / multi-tab / list). Call this first when entering a new page to understand its structure before acting. Use this INSTEAD of ai_act if you just want to observe — ai_act is for changing state, not for looking around.",
56
+ description: "Get a natural language summary of the current screen, including layout type (fixed single-screen / scrollable long page / multi-tab / list). Call this first when entering a new page to understand its structure before acting. The summary is saved internally; if you then call ai_act, it will be used as the before-state for change comparison. Use this INSTEAD of ai_act if you just want to observe — ai_act is for changing state, not for looking around.",
57
57
  inputSchema: {
58
58
  sessionId: z.string().describe("Session ID from exploration_start"),
59
59
  },
@@ -76,7 +76,7 @@ export function registerExplorationTools(server, ctx) {
76
76
  // ---------------------------------------------------------------------------
77
77
  server.registerTool("exploration_ai_act", {
78
78
  title: "AI Act",
79
- description: "Perform a high-level UI interaction described in natural language. Examples: 'Go back', 'Tap the settings icon', 'Type text in the search box'. After execution, returns a summary of the new page state including whether the action actually changed anything.\n\nIMPORTANT: Use get_page_summary FIRST to check if the page is a fixed single-screen layout. If it is, do NOT request scroll actions — there is nothing to scroll to. Use ai_act only for meaningful interactions (tap, type, swipe between tabs), not for 'look around' or 'scroll to see more'. If the post-action summary reports that the page did not change, stop acting on this page and move on.",
79
+ description: "Perform a high-level UI interaction described in natural language. Examples: 'Go back', 'Tap the settings icon', 'Type text in the search box'. After execution, returns a summary of the new page state including whether the action actually changed anything.\n\nIMPORTANT: Use get_page_summary FIRST to check if the page is a fixed single-screen layout. If it is, do NOT request scroll actions — there is nothing to scroll to. Use ai_act only for meaningful interactions (tap, type, swipe between tabs), not for 'look around' or 'scroll to see more'. If the post-action summary reports that the page did not change, stop acting on this page and move on.\n\nNote on before/after comparison: The AI model is stateless — each summary only sees the current screenshot. To detect changes, ai_act internally captures a before-state description (either from your prior get_page_summary call, or by grabbing a quick snapshot before acting) and passes it to the after-summary for comparison. For richer change detection, call get_page_summary before ai_act.",
80
80
  inputSchema: {
81
81
  sessionId: z.string().describe("Session ID from exploration_start"),
82
82
  intent: z.string().describe("Description of what to do"),
@@ -24,6 +24,7 @@ async function persistMeta(state, env) {
24
24
  resourceId: state.resourceId,
25
25
  platform: state.platform,
26
26
  env,
27
+ appRef: state.appRef,
27
28
  createdAt: state.createdAt,
28
29
  lastActivityAt: state.lastActivityAt,
29
30
  };
@@ -50,12 +51,13 @@ async function removeMeta(id) {
50
51
  // Session lifecycle
51
52
  // ---------------------------------------------------------------------------
52
53
  /** Create a new exploration session and persist metadata to disk. */
53
- export function createSession(id, resourceId, platform, session, env) {
54
+ export function createSession(id, resourceId, platform, session, env, appRef) {
54
55
  const state = {
55
56
  id,
56
57
  resourceId,
57
58
  platform,
58
59
  session,
60
+ appRef,
59
61
  createdAt: Date.now(),
60
62
  lastActivityAt: Date.now(),
61
63
  };
@@ -88,6 +90,7 @@ export function storeSession(id, session, meta) {
88
90
  resourceId: meta.resourceId,
89
91
  platform: meta.platform,
90
92
  session,
93
+ appRef: meta.appRef,
91
94
  createdAt: meta.createdAt,
92
95
  lastActivityAt: Date.now(),
93
96
  };
@@ -1,4 +1,65 @@
1
+ import http from "node:http";
2
+ import { execSync } from "node:child_process";
1
3
  import { resolveSession } from "./tools-session.js";
4
+ import { getSession } from "./sessionManager.js";
5
+ async function detectIosForegroundApp(session) {
6
+ if (session.platform !== "ios")
7
+ return null;
8
+ const { wdaHost, wdaPort } = session.target;
9
+ return new Promise((resolve) => {
10
+ const req = http.get(`http://${wdaHost}:${wdaPort}/wda/activeAppInfo`, { timeout: 3000 }, (res) => {
11
+ let body = "";
12
+ res.on("data", (chunk) => { body += chunk; });
13
+ res.on("end", () => {
14
+ try {
15
+ const data = JSON.parse(body);
16
+ if (data?.value?.bundleId) {
17
+ resolve({ bundleId: data.value.bundleId, name: data.value.name });
18
+ }
19
+ else {
20
+ resolve(null);
21
+ }
22
+ }
23
+ catch {
24
+ resolve(null);
25
+ }
26
+ });
27
+ });
28
+ req.on("error", () => resolve(null));
29
+ req.on("timeout", () => { req.destroy(); resolve(null); });
30
+ });
31
+ }
32
+ function detectAndroidForegroundApp(session) {
33
+ if (session.platform !== "android")
34
+ return null;
35
+ const { serial, adbHost, adbPort } = session.target;
36
+ try {
37
+ const output = execSync(`adb -H ${adbHost} -P ${adbPort} -s ${serial} shell dumpsys window 2>/dev/null | grep -E 'mCurrentFocus|mFocusedApp'`, { encoding: "utf8", timeout: 5000 });
38
+ // Match patterns like:
39
+ // mCurrentFocus=Window{... com.example.app/...}
40
+ // mFocusedApp=AppWindowToken{... token=... appPackageName=com.example.app}
41
+ const focusMatch = output.match(/mCurrentFocus[=:].*?\s+([^\s/}]+)/);
42
+ const packageMatch = output.match(/appPackageName=([^\s}]+)/);
43
+ if (focusMatch)
44
+ return { bundleId: focusMatch[1] };
45
+ if (packageMatch)
46
+ return { bundleId: packageMatch[1] };
47
+ }
48
+ catch {
49
+ // adb not reachable or no foreground window
50
+ }
51
+ return null;
52
+ }
53
+ async function detectForegroundApp(session) {
54
+ if (session.platform === "ios")
55
+ return detectIosForegroundApp(session);
56
+ if (session.platform === "android")
57
+ return detectAndroidForegroundApp(session);
58
+ return null;
59
+ }
60
+ // ---------------------------------------------------------------------------
61
+ // Tool handlers
62
+ // ---------------------------------------------------------------------------
2
63
  export function getPageSummaryHandler(ctx) {
3
64
  return async (input) => {
4
65
  const session = await resolveSession(input.sessionId, ctx);
@@ -8,7 +69,20 @@ export function getPageSummaryHandler(ctx) {
8
69
  "2) 是否有弹窗、广告或遮挡物?\n" +
9
70
  "3) 整体布局类型:固定单屏 / 可滚动长页面 / 多Tab / 列表\n" +
10
71
  "先判断布局类型,再逐一描述每个区域的内容。");
11
- return { summary };
72
+ const state = getSession(input.sessionId);
73
+ const foregroundApp = await detectForegroundApp(session);
74
+ const appRef = state.appRef;
75
+ // Save for aiActHandler's before/after comparison
76
+ state.lastPageSummary = summary;
77
+ return {
78
+ summary,
79
+ app: {
80
+ platform: session.platform,
81
+ resourceId: state.resourceId,
82
+ ...(appRef ? { appRef } : {}),
83
+ ...(foregroundApp ? { foregroundApp } : {}),
84
+ },
85
+ };
12
86
  };
13
87
  }
14
88
  export function askAboutScreenHandler(ctx) {
@@ -21,13 +95,30 @@ export function askAboutScreenHandler(ctx) {
21
95
  export function aiActHandler(ctx) {
22
96
  return async (input) => {
23
97
  const session = await resolveSession(input.sessionId, ctx);
98
+ const state = getSession(input.sessionId);
99
+ // Before-state: reuse from get_page_summary if available, otherwise grab a quick one
100
+ const beforeSummary = state.lastPageSummary ?? await session.agent.aiAsk("用一句话描述当前页面的最关键特征:什么类型的页面(列表/表单/弹窗/首页等),最显著的内容是什么。");
24
101
  await session.agent.aiAct(input.intent);
25
- const afterSummary = await session.agent.aiAsk("刚刚的操作已完成。请判断:\n" +
26
- "1) 操作是否改变了页面内容?(新页面、弹窗、滚动到底部、输入框获得焦点等)\n" +
27
- "2) 如果操作是滑动页面,是否滑到了底部或页面内容没有变化?\n" +
102
+ const afterSummary = await session.agent.aiAsk(`操作前的页面:${beforeSummary}\n` +
103
+ `执行的操作:${input.intent}\n` +
104
+ "请判断操作结果:\n" +
105
+ "1) 页面内容是否发生了变化?(进入了新页面、弹出弹窗、滚动到底部、输入框获得焦点等)\n" +
106
+ "2) 如果操作是滑动,是否已到达底部或页面没有变化?\n" +
28
107
  "3) 当前页面布局类型是固定单屏还是可滚动长页面?\n" +
29
- "4) 当前页面出现的最关键变化是什么?\n" +
30
- "如果你发现操作没有产生任何实际变化(比如反复滑动但没有新内容),请明确指出\"页面没有变化\"。");
31
- return { ok: true, summary: afterSummary };
108
+ "4) 当前页面最关键的变化是什么?\n" +
109
+ "如果操作没有产生任何实际变化(比如反复滑动但没有新内容),请明确指出\"页面没有变化\"。");
110
+ // Update for next aiAct call
111
+ state.lastPageSummary = afterSummary;
112
+ const foregroundApp = await detectForegroundApp(session);
113
+ return {
114
+ ok: true,
115
+ summary: afterSummary,
116
+ app: {
117
+ platform: session.platform,
118
+ resourceId: state.resourceId,
119
+ ...(state.appRef ? { appRef: state.appRef } : {}),
120
+ ...(foregroundApp ? { foregroundApp } : {}),
121
+ },
122
+ };
32
123
  };
33
124
  }
@@ -241,25 +241,26 @@ export function getExplorationStartHandler(ctx) {
241
241
  }
242
242
  const session = await createMidsceneSessionFromResourceId(resourceId, runtimeEnv);
243
243
  const sessionId = generateSessionId();
244
- createSession(sessionId, resourceId, platform, session, runtimeEnv);
244
+ const appRef = input.appRef;
245
+ createSession(sessionId, resourceId, platform, session, runtimeEnv, appRef);
245
246
  try {
246
- if (input.appRef) {
247
- if (isInstallableRef(input.appRef)) {
248
- await ctx.client.installApp(resourceId, input.appRef);
247
+ if (appRef) {
248
+ if (isInstallableRef(appRef)) {
249
+ await ctx.client.installApp(resourceId, appRef);
249
250
  return {
250
251
  sessionId,
251
252
  device: { platform, resourceId },
252
- note: `App installed from ${input.appRef}. Use exploration_ai_act to launch it.`,
253
+ note: `App installed from ${appRef}. Use exploration_ai_act to launch it.`,
253
254
  };
254
255
  }
255
- await session.agent.launch(input.appRef);
256
+ await session.agent.launch(appRef);
256
257
  }
257
258
  }
258
259
  catch (err) {
259
260
  await destroySessionById(sessionId).catch(() => { });
260
261
  throw err;
261
262
  }
262
- return { sessionId, device: { platform, resourceId } };
263
+ return { sessionId, device: { platform, resourceId }, appRef: appRef ?? undefined };
263
264
  };
264
265
  }
265
266
  export function getExplorationEndHandler() {
@@ -1,28 +1,62 @@
1
1
  import { spawn } from "node:child_process";
2
- import { cp, mkdir, rm, writeFile } from "node:fs/promises";
2
+ import { access, cp, mkdir, rm, writeFile } from "node:fs/promises";
3
3
  import { homedir } from "node:os";
4
- import { join } from "node:path";
4
+ import { basename, dirname, join, sep } from "node:path";
5
5
  export async function installLocalRuntime(options) {
6
6
  const runtimeRoot = options.runtimeRoot ?? join(homedir(), ".preflight", "runtime");
7
- await runNpm(["run", "build"], options.projectRoot);
7
+ const sourceRoot = options.sourceRoot ?? options.projectRoot;
8
+ const targetProjectRoot = options.targetProjectRoot ?? options.projectRoot ?? process.cwd();
9
+ const npmRunner = options.runNpm ?? runNpm;
10
+ if (!sourceRoot) {
11
+ throw new Error("Preflight runtime source root is required.");
12
+ }
13
+ if (await exists(join(sourceRoot, "tsconfig.build.json"))) {
14
+ await npmRunner(["run", "build"], sourceRoot);
15
+ }
8
16
  await rm(runtimeRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 500 });
9
17
  await mkdir(runtimeRoot, { recursive: true });
10
18
  await mkdir(join(runtimeRoot, "node", "bin"), { recursive: true });
11
19
  await cp(process.execPath, join(runtimeRoot, "node", "bin", "node"));
12
- await cp(join(options.projectRoot, "dist"), join(runtimeRoot, "dist"), { recursive: true });
13
- await cp(join(options.projectRoot, "package.json"), join(runtimeRoot, "package.json"));
14
- await cp(join(options.projectRoot, "package-lock.json"), join(runtimeRoot, "package-lock.json"));
15
- await cp(join(options.projectRoot, "scripts"), join(runtimeRoot, "scripts"), { recursive: true }).catch(() => { });
20
+ await cp(join(sourceRoot, "dist"), join(runtimeRoot, "dist"), { recursive: true });
21
+ await cp(join(sourceRoot, "package.json"), join(runtimeRoot, "package.json"));
22
+ await cp(join(sourceRoot, "package-lock.json"), join(runtimeRoot, "package-lock.json")).catch(() => { });
23
+ await cp(join(sourceRoot, "scripts"), join(runtimeRoot, "scripts"), { recursive: true }).catch(() => { });
16
24
  await mkdir(join(runtimeRoot, "docs"), { recursive: true });
17
- await cp(join(options.projectRoot, "docs", "visual-flow-ir-llm.md"), join(runtimeRoot, "docs", "visual-flow-ir-llm.md"));
18
- await runNpm(["ci", "--omit=dev"], runtimeRoot);
19
- await writeFile(join(runtimeRoot, "preflight-runtime.json"), `${JSON.stringify({ installedAt: new Date().toISOString(), source: options.projectRoot }, null, 2)}\n`, "utf8");
25
+ await cp(join(sourceRoot, "docs", "visual-flow-ir-llm.md"), join(runtimeRoot, "docs", "visual-flow-ir-llm.md"));
26
+ const reusedDependencies = await reuseInstalledDependencies(sourceRoot, runtimeRoot);
27
+ if (!reusedDependencies) {
28
+ await npmRunner(await exists(join(runtimeRoot, "package-lock.json")) ? ["ci", "--omit=dev"] : ["install", "--omit=dev"], runtimeRoot);
29
+ }
30
+ await writeFile(join(runtimeRoot, "preflight-runtime.json"), `${JSON.stringify({ installedAt: new Date().toISOString(), source: sourceRoot, targetProjectRoot }, null, 2)}\n`, "utf8");
20
31
  return {
21
32
  runtimeRoot,
22
33
  nodeBin: join(runtimeRoot, "node", "bin", "node"),
23
34
  mcpEntry: join(runtimeRoot, "dist", "mcp", "cli.js"),
24
35
  };
25
36
  }
37
+ async function reuseInstalledDependencies(sourceRoot, runtimeRoot) {
38
+ const packageNodeModules = join(sourceRoot, "node_modules");
39
+ if (await exists(packageNodeModules)) {
40
+ await cp(packageNodeModules, join(runtimeRoot, "node_modules"), { recursive: true });
41
+ return true;
42
+ }
43
+ const parentNodeModules = dirname(sourceRoot);
44
+ const isNpxPackage = basename(parentNodeModules) === "node_modules" && parentNodeModules.includes(`${sep}_npx${sep}`);
45
+ if (isNpxPackage) {
46
+ await cp(parentNodeModules, join(runtimeRoot, "node_modules"), { recursive: true });
47
+ return true;
48
+ }
49
+ return false;
50
+ }
51
+ async function exists(path) {
52
+ try {
53
+ await access(path);
54
+ return true;
55
+ }
56
+ catch {
57
+ return false;
58
+ }
59
+ }
26
60
  function run(command, args, cwd) {
27
61
  return new Promise((resolve, reject) => {
28
62
  const child = spawn(command, args, { cwd, stdio: "inherit" });
package/dist/mcp/setup.js CHANGED
@@ -7,7 +7,11 @@ export async function setupLocalMcp(options) {
7
7
  const livePort = options.livePort ?? 18999;
8
8
  const shouldInstallRuntime = options.installRuntime ?? true;
9
9
  const installedRuntime = shouldInstallRuntime
10
- ? await installLocalRuntime({ projectRoot: options.projectRoot, runtimeRoot: options.runtimeRoot })
10
+ ? await installLocalRuntime({
11
+ sourceRoot: options.runtimeSourceRoot ?? options.projectRoot,
12
+ targetProjectRoot: options.projectRoot,
13
+ runtimeRoot: options.runtimeRoot,
14
+ })
11
15
  : undefined;
12
16
  const runtimeRoot = installedRuntime?.runtimeRoot ?? options.runtimeRoot ?? process.env.AGENT_RUNTIME_ROOT?.trim();
13
17
  const isRuntime = !!runtimeRoot;
@@ -0,0 +1,153 @@
1
+ # 可视化编排 IR(供模型生成 JSON)
2
+
3
+ 本文档供 **对话编排 / 自动生成 `visualFlow`** 的系统提示使用:只描述 **JSON 形状、字段含义与校验约束**,不含代码生成、HTTP 接口等平台实现细节。人类可读的平台语义、codegen 对照与落库说明见 `**VISUAL_FLOW_IR.md`**;字段以 `**backend/src/contexts/visual-flow/types.ts`**、校验以 `**validate.ts`** 为准。
4
+
5
+ ---
6
+
7
+ ## 1. 根对象 `VisualFlowDocument`
8
+
9
+
10
+ | 字段 | 必填 | 说明 |
11
+ | ------------ | --- | ------------------------------------ |
12
+ | `version` | 是 | 固定为数字 `1`,其它值保存失败。 |
13
+ | `scriptVars` | 否 | 执行前由人填的变量声明数组;步骤文案里用 `{{变量名}}` 引用。 |
14
+ | `steps` | 是 | 顶层步骤数组,按顺序执行;**展开后总条数 ≤ 500**(含子步骤)。 |
15
+
16
+
17
+ ### 1.1 `scriptVars[]` 每项
18
+
19
+
20
+ | 字段 | 必填 | 选填 / 备注 |
21
+ | -------------- | --- | ---------------------------------- |
22
+ | `name` | 是 | — |
23
+ | `description` | 否 | 给人看的说明。 |
24
+ | `defaultValue` | 否 | 默认填值,字符串。 |
25
+ | `scope` | 否 | `global`、`local` 或 `temp`;缺省按平台约定。 |
26
+
27
+
28
+ ---
29
+
30
+ ## 2. 步骤类型与设计原则
31
+
32
+ ### 核心原则:所有 UI 交互都使用 `aiAct`
33
+
34
+ - `aiAct` 是**唯一**的 UI 交互步骤类型。
35
+ - 一个 `aiAct` 覆盖**一个完整的用户意图**,由视觉模型自行规划具体的点击、长按、滑动、输入等动作。
36
+ - 例如,"长按第一个订单,在弹出菜单中选择「删除」,如果有确认弹窗则确认删除"——这是一个完整的意图,应当用**一个** `aiAct` 表达。
37
+
38
+ ### 其他步骤类型
39
+
40
+ 除 `aiAct` 外的步骤类型分为以下几类,各自有明确的非交互用途:
41
+
42
+ | 类别 | 类型 | 说明 |
43
+ |------|------|------|
44
+ | **应用管理** | `launch` / `closeApp` / `installApp` / `uninstallApp` | 启动、关闭、安装、卸载应用 |
45
+ | **等待** | `sleep` | 固定延时等待(页面跳转、动画、列表更新等) |
46
+ | **断言** | `assert` | 视觉断言,验证 UI 状态是否符合预期 |
47
+ | **上下文** | `setAIActContext` | 设置后续 `aiAct` 的突发情况处理策略(如权限弹窗、营销弹窗) |
48
+ | **报告** | `recordToReport` | 向测试报告写入标题和内容 |
49
+ | **变量** | `setVar` / `assignVar` / `transformVar` | 从屏幕读取数据、赋值或转换变量 |
50
+ | **流程控制** | `if` / `ifDeviceType` / `whileLoop` / `forLoop` | 条件判断、按设备类型分支、循环 |
51
+ | **脚本调用** | `callScript` | 调用其他测试用例 |
52
+
53
+ ### 每条步骤的公共规则
54
+
55
+ - 每个步骤对象 **必须有** `type`(字符串)。
56
+ - `**launch` / `closeApp` / `uninstallApp`**:使用包名类字段(`packageName` 或 `bundleId`)表达应用目标。
57
+ - `aiAct` / `assert` / `setAIActContext`:用 `prompt` 写 **短而可执行** 的界面描述(可见文案、区域)。
58
+ - 突发情况处理使用 `setAIActContext`:例如 `"遇到权限弹窗请同意,营销弹窗请拒绝"`。该上下文会带给后续 act 操作,由视觉模型自行处理弹窗等临时干扰。
59
+ - `prompt` 保持简洁明确,只写必要信息。**`assert` 的 `prompt` 只需写明判断逻辑**,已有变量用 `{{}}` 插值即可,不要重复上下文。好:`"屏幕上的登录状态文本是{{expectedStatus}}"`;差:一段描述"看当前屏幕……由于……说明……"的长句。
60
+ - 页面跳转、启动、刷新、动画结束、列表更新等"页面稳定"场景,使用 `sleep` 固定等待(如 2000~5000ms)。
61
+ - `assert` 放在关键校验点,通常是本次改动点或必要回归点;普通步骤执行失败会自然阻塞流程。
62
+ - `**if` / `ifDeviceType`**:`thenSteps` 非空数组;`**elseSteps`** 为 **选填**:可整段省略、或 `[]`、或 `null`(均表示「无 else 分支」);若写出 `elseSteps` 且为非空数组,则其中为正常子步骤。
63
+ - `**whileLoop` / `forLoop`**:`bodySteps` **非空数组**(无选填分支数组名)。
64
+ - **同一条「线性执行路径」上**:`setVar` 的 `name` **不得重复**(含分支内继承规则,与校验一致)。
65
+ - `**setVar.name` / `assignVar.name` / `transformVar.name`**:支持 Unicode 字母(含中文)/数字/`_`/`$`,且首字符为非数字;须匹配 `^[$_\\p{L}][\\p{L}\\p{N}_$]*$`(`/u` 语义),长度 1~64,并避开运行时保留名(如 `agent`、`page`、`console`、`process`、`sleep` 等,详见校验)。
66
+ - 字符串字段中引用已声明变量时,**必须**使用 `**{{变量名}}`**、`{{变量名[0]}}`、`{{变量名.1}}`、`{{变量名.length}}` 等插值形式;其中 `.length` 用于读取数组长度(非数组按 `0` 处理)。
67
+ - 已声明变量在字符串字段中统一使用插值形式;例如已声明 `timeBefore`、`timeAfter` 时,写成 `"{{timeBefore}}和{{timeAfter}}不同"`。
68
+
69
+ ---
70
+
71
+ ## 3. 步骤类型:必填、选填与约束
72
+
73
+ 列 **选填**:除「—」外,写出可出现的字段名;未列出者表示不宜随意加未知键(以 `types.ts` 为准)。
74
+
75
+
76
+ | `type` | 必填字段 | 选填字段 | 说明与取值约束 |
77
+ | ---------------------------------------------------------------- | ----------------------------------------------- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
78
+ | `launch` | `packageName` | — | Android 包名 / iOS bundle id / 鸿蒙 bundle name。 |
79
+ | `installApp` | `appRef` | — | 本地路径、`file://` 或 `http(s)` 安装包地址。 |
80
+ | `uninstallApp` | `bundleId` | — | 要卸载的应用标识。 |
81
+ | `closeApp` | `packageName` | — | 关闭正在运行的应用。 |
82
+ | `setAIActContext` | `prompt` | — | 设置后续 aiAct 操作的上下文,适合声明权限弹窗、营销弹窗、升级提示等突发情况处理策略。 |
83
+ | `recordToReport` | `title`, `content` | — | 二者均为字符串,**允许空串**;有长度上限(校验)。 |
84
+ | `assert` | `prompt` | — | 断言命题。 |
85
+ | `sleep` | `ms` | — | 整数 **0~3600000**(毫秒);页面稳定、动画结束、跳转缓冲使用本步骤。 |
86
+ | `aiAct` | `prompt` | — | **唯一的 UI 交互步骤类型**。描述完整的用户意图、关键约束和完成条件,让视觉模型自行规划具体操作。 |
87
+ | `if` | `conditionPrompt`, `thenSteps` | `**elseSteps`** | 条件为真执行 `thenSteps`;无 else 则省略 `elseSteps` / `[]` / `null`。 |
88
+ | `ifDeviceType` | `interfaceType`, `thenSteps` | `**elseSteps`** | `interfaceType` 仅 `android`、`ios`、`harmony`;无 else 同上。 |
89
+ | `whileLoop` | `conditionPrompt`, `maxIterations`, `bodySteps` | — | `maxIterations`:1~1000 整数。 |
90
+ | `forLoop` | `count`, `bodySteps` | — | `count`:1~500 整数。 |
91
+ | `setVar` | `name`, `method`, `expression` | — | `method` 仅 `aiQuery`、`aiAsk`、`aiBoolean`、`aiNumber`、`aiString`。`aiQuery` 的 `expression` 可为自然语言或整段 JSON 抽取需求;其余 method 的 `expression` 为对应 API 的 prompt。 |
92
+ | `assignVar` | `name`, `value` | — | 字面值或带 `{{}}` 的模板;**不用**于从屏读数(读屏用 `setVar`)。 |
93
+ | `transformVar` | `name`, `rule` | `source`、`start`、`end`、`jsonPath`、`pattern`、`replacement` | `rule`:`onlyNumber`、`cut`、`jsonPath`、`replace`、`handleAmount`;按规则选用上述选填字段。 |
94
+ | `callScript` | `targetTestCaseId`, `scopeId`, `varBindings` | `**targetName`** | `targetTestCaseId`:24 位 hex;`scopeId`:`sub` + 12 位小写 hex;`varBindings` 可为 `{}`;`targetName` 仅展示用。 |
95
+
96
+
97
+ 步骤 `type` 使用上表枚举;生成后先调用 `validate_visual_flow`,按返回信息修正结构。
98
+
99
+ ---
100
+
101
+ ## 4. 易错校验(生成后自查)
102
+
103
+ - `version !== 1` → 失败。
104
+ - `if` / `ifDeviceType` 的 `thenSteps` 为空,或 `whileLoop` / `forLoop` 的 `bodySteps` 为空 → 失败。
105
+ - `sleep.ms`、`whileLoop.maxIterations`、`forLoop.count` 超出上表范围 → 失败。
106
+ - `callScript.targetTestCaseId` 非 24 位 hex,或 `scopeId` 不符合 `sub`+12hex → 失败。
107
+ - 同一路径重复 `setVar.name` → 失败。
108
+ - 声明过的变量在 `prompt` / `value` / `expression` / `varBindings` 等字符串字段中使用 `{{}}` 插值。
109
+ - 页面稳定等待使用 `sleep`;关键结果检查使用 `assert`。
110
+ - `assert` 聚焦关键校验点,通常是本次改动点或必要回归点。
111
+ - **所有 UI 交互必须使用 `aiAct`**。
112
+
113
+ ---
114
+
115
+ ## 5. 完整示例(含 `scriptVars`、条件分支、`assert`)
116
+
117
+ 下列为**可直接通过结构校验**的示意:请把包名、文案改成目标应用真实情况。
118
+
119
+ ```json
120
+ {
121
+ "version": 1,
122
+ "scriptVars": [
123
+ { "name": "phone", "description": "登录手机号", "defaultValue": "" }
124
+ ],
125
+ "steps": [
126
+ { "type": "launch", "packageName": "com.example.app" },
127
+ { "type": "setAIActContext", "prompt": "遇到权限弹窗请同意,营销弹窗请拒绝" },
128
+ { "type": "sleep", "ms": 3000 },
129
+ {
130
+ "type": "if",
131
+ "conditionPrompt": "当前是否已在登录页(能看到手机号输入框)",
132
+ "thenSteps": [
133
+ {
134
+ "type": "aiAct",
135
+ "prompt": "在手机号输入框中输入{{phone}},然后点击「获取验证码」按钮"
136
+ },
137
+ { "type": "sleep", "ms": 3000 },
138
+ { "type": "assert", "prompt": "出现短信验证码输入框或倒计时提示" }
139
+ ],
140
+ "elseSteps": [
141
+ { "type": "aiAct", "prompt": "点击底部或顶部的「我的」或个人中心入口" }
142
+ ]
143
+ },
144
+ { "type": "assert", "prompt": "页面显示已登录态(头像、昵称或「退出登录」其一)" },
145
+ { "type": "sleep", "ms": 500 },
146
+ { "type": "closeApp", "packageName": "com.example.app" }
147
+ ]
148
+ }
149
+ ```
150
+
151
+ ---
152
+
153
+ **维护约定**:新增或变更步骤类型时,须同步 `**types.ts`** / `**validate.ts`** / `**VISUAL_FLOW_IR_LLM.md`** / `**VISUAL_FLOW_IR.md`**,保持模型提示与保存校验一致。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "preflite",
3
- "version": "1.0.1",
3
+ "version": "1.1.1",
4
4
  "description": "Preflight — Local mobile AI testing via MCP. AI-assisted testing on real Android/iOS/Harmony devices.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -9,6 +9,8 @@
9
9
  },
10
10
  "files": [
11
11
  "dist/",
12
+ "docs/visual-flow-ir-llm.md",
13
+ "scripts/",
12
14
  "README.md",
13
15
  "LICENSE"
14
16
  ],
@@ -0,0 +1,4 @@
1
+ # hdc bridge
2
+ # Usage: eval "$(hdc-bridge.sh)" or source it
3
+ # Should not be executed directly
4
+ echo "hdc() { command hdc -s \"${HDC_S:-127.0.0.1:17815}\" \"\$@\"; }"
@@ -0,0 +1,31 @@
1
+ # shellcheck shell=bash
2
+ # Sourced by run-midscene-task.sh: load nvm and run `nvm use` in the repo root.
3
+ # No side effects: failure does not exit; caller decides whether to error on insufficient Node version.
4
+
5
+ _try_source_nvm() {
6
+ if [[ -n "${NVM_DIR:-}" && -s "${NVM_DIR}/nvm.sh" ]]; then
7
+ # shellcheck disable=SC1090
8
+ source "${NVM_DIR}/nvm.sh"
9
+ return 0
10
+ fi
11
+ local d
12
+ for d in "${HOME}/.nvm" "/usr/local/opt/nvm"; do
13
+ if [[ -s "${d}/nvm.sh" ]]; then
14
+ export NVM_DIR="${d}"
15
+ # shellcheck disable=SC1090
16
+ source "${d}/nvm.sh"
17
+ return 0
18
+ fi
19
+ done
20
+ return 1
21
+ }
22
+
23
+ _nvm_use_repo() {
24
+ local repo_root="${1:?repo root}"
25
+ _try_source_nvm || return 1
26
+ local dsave="${PWD}"
27
+ cd "${repo_root}" || return 1
28
+ nvm use >/dev/null 2>&1 || true
29
+ cd "${dsave}" || true
30
+ return 0
31
+ }
@@ -0,0 +1,43 @@
1
+ #!/usr/bin/env bash
2
+ # Midscene subprocess entry: prefer Node/tsx from runtime package; fall back to nvm + npx in dev mode.
3
+ set -euo pipefail
4
+
5
+ TASK_SCRIPT="${1:?usage: run-midscene-task.sh <path-to-task-script.ts>}"
6
+
7
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
8
+ REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
9
+ # shellcheck disable=SC1091
10
+ source "${SCRIPT_DIR}/nvm-use-repo.sh"
11
+
12
+ cd "${REPO_ROOT}"
13
+
14
+ NODE_BIN="${PREFLIGHT_RUNTIME_NODE:-}"
15
+ if [[ -z "${NODE_BIN}" && -x "${REPO_ROOT}/node/bin/node" ]]; then
16
+ NODE_BIN="${REPO_ROOT}/node/bin/node"
17
+ fi
18
+ if [[ -z "${NODE_BIN}" ]]; then
19
+ _nvm_use_repo "${REPO_ROOT}" || true
20
+ NODE_BIN="$(command -v node || true)"
21
+ fi
22
+ if [[ -z "${NODE_BIN}" || ! -x "${NODE_BIN}" ]]; then
23
+ echo "[run-midscene-task] Node not found. Install Node 20+ or use the runtime package's node/bin/node." >&2
24
+ exit 1
25
+ fi
26
+
27
+ if [[ -d "${REPO_ROOT}/node/bin" ]]; then
28
+ export PATH="${REPO_ROOT}/node/bin:${PATH}"
29
+ fi
30
+
31
+ major="$("${NODE_BIN}" -p "parseInt(process.versions.node,10)" 2>/dev/null || echo 0)"
32
+ if [[ "${major}" -lt 20 ]]; then
33
+ echo "[run-midscene-task] Node >= 20 required (current: $("${NODE_BIN}" -v 2>/dev/null || echo unknown))." >&2
34
+ echo "[run-midscene-task] Install Node 20+ or set PREFLIGHT_RUNTIME_NODE or MIDSCENE_RUN_COMMAND to override." >&2
35
+ exit 1
36
+ fi
37
+
38
+ TSX_CLI="${REPO_ROOT}/node_modules/tsx/dist/cli.mjs"
39
+ if [[ -f "${TSX_CLI}" ]]; then
40
+ exec "${NODE_BIN}" "${TSX_CLI}" "${TASK_SCRIPT}"
41
+ fi
42
+
43
+ exec npx tsx "${TASK_SCRIPT}"
@@ -0,0 +1,328 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ UDID="${1:-}"
5
+ WDA_PORT="${2:-}"
6
+
7
+ EXIT_MISSING_ARGS=1
8
+ EXIT_DEVICE_NOT_CONNECTED=2
9
+ EXIT_WDA_START_FAILED=3
10
+ EXIT_DEPENDENCY_MISSING=4
11
+
12
+ if [[ -z "${UDID}" || -z "${WDA_PORT}" ]]; then
13
+ echo "[start-ios-wda] usage: $0 <UDID> <WDA_PORT>"
14
+ exit "${EXIT_MISSING_ARGS}"
15
+ fi
16
+
17
+ if ! [[ "${WDA_PORT}" =~ ^[0-9]+$ ]]; then
18
+ echo "[start-ios-wda] invalid WDA_PORT: ${WDA_PORT}"
19
+ exit "${EXIT_MISSING_ARGS}"
20
+ fi
21
+
22
+ # Local MJPEG port: matches Agent / Midscene convention, default = WDA local port + 1000 → device:9100 (WDA video stream)
23
+ MJPEG_LOCAL_PORT=$((WDA_PORT + 1000))
24
+ if [[ "${MJPEG_LOCAL_PORT}" -gt 65535 ]]; then
25
+ echo "[start-ios-wda] WDA_PORT+1000 exceeds max port: ${WDA_PORT} -> ${MJPEG_LOCAL_PORT}"
26
+ exit "${EXIT_MISSING_ARGS}"
27
+ fi
28
+
29
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
30
+ REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
31
+ AGENT_HOME="${AGENT_HOME:-${HOME}/.preflight}"
32
+
33
+ WDA_ROOT="${WDA_PROJECT_ROOT:-${REPO_ROOT}/third_party/WebDriverAgent}"
34
+ WDA_PROJECT="${WDA_PROJECT_PATH:-${WDA_ROOT}/WebDriverAgent.xcodeproj}"
35
+ WDA_SCHEME="${WDA_SCHEME:-WebDriverAgentRunner}"
36
+ WDA_DERIVED_DATA="${WDA_DERIVED_DATA:-${AGENT_HOME}/state/wda-derived-data/${UDID}}"
37
+
38
+ WDA_LOG_DIR="${WDA_LOG_DIR:-${AGENT_HOME}/logs/wda}"
39
+ WDA_LOG_FILE="${WDA_LOG_DIR}/wda-${UDID}-${WDA_PORT}.log"
40
+ IPROXY_LOG_FILE="${WDA_LOG_DIR}/iproxy-${UDID}-${WDA_PORT}.log"
41
+
42
+ WDA_STATUS_URL="http://127.0.0.1:${WDA_PORT}/status"
43
+
44
+ mkdir -p "${WDA_LOG_DIR}" "${WDA_DERIVED_DATA}"
45
+
46
+ # Lock file to prevent concurrent script instances (the watchdog polls every 5s,
47
+ # but build-for-testing can take minutes). Uses PID-based stale detection.
48
+ BUILD_LOCK_DIR="${AGENT_HOME}/state/wda-locks"
49
+ mkdir -p "${BUILD_LOCK_DIR}"
50
+ BUILD_LOCK="${BUILD_LOCK_DIR}/wda-start-${UDID}-${WDA_PORT}.pid"
51
+
52
+ try_acquire_lock() {
53
+ if [[ -f "${BUILD_LOCK}" ]]; then
54
+ local old_pid
55
+ old_pid=$(cat "${BUILD_LOCK}" 2>/dev/null)
56
+ if [[ -n "${old_pid}" ]] && kill -0 "${old_pid}" 2>/dev/null; then
57
+ echo "[start-ios-wda] another instance already running (pid=${old_pid}), skipping"
58
+ return 1
59
+ fi
60
+ echo "[start-ios-wda] removing stale lock from pid=${old_pid}"
61
+ rm -f "${BUILD_LOCK}"
62
+ fi
63
+ echo "$$" > "${BUILD_LOCK}"
64
+ trap 'rm -f "${BUILD_LOCK}"' EXIT
65
+ return 0
66
+ }
67
+
68
+ if [[ ! -d "${WDA_PROJECT}" ]]; then
69
+ echo "[start-ios-wda] WDA project not found: ${WDA_PROJECT}"
70
+ echo "[start-ios-wda] run: npm run clone:wda"
71
+ exit 1
72
+ fi
73
+
74
+ for cmd in xcrun curl iproxy xcodebuild; do
75
+ if ! command -v "${cmd}" >/dev/null 2>&1; then
76
+ echo "[start-ios-wda] missing dependency: ${cmd}"
77
+ if [[ "${cmd}" == "iproxy" ]]; then
78
+ echo "[start-ios-wda] install it with: brew install libimobiledevice"
79
+ fi
80
+ exit "${EXIT_DEPENDENCY_MISSING}"
81
+ fi
82
+ done
83
+
84
+ device_is_online() {
85
+ # Check physical devices first (== Devices ==)
86
+ xcrun xctrace list devices 2>/dev/null | awk '
87
+ /^== Devices ==/ { in_devices=1; next }
88
+ /^== / { in_devices=0 }
89
+ in_devices { print }
90
+ ' | grep -F "(${UDID})" >/dev/null 2>&1 && return 0
91
+
92
+ # Then check booted simulators
93
+ xcrun simctl list devices booted 2>/dev/null | grep -F "(${UDID})" >/dev/null 2>&1
94
+ }
95
+
96
+ device_is_simulator() {
97
+ xcrun simctl list devices booted 2>/dev/null | grep -F "(${UDID})" >/dev/null 2>&1
98
+ }
99
+
100
+ wda_is_healthy() {
101
+ curl -fsS --max-time 2 "${WDA_STATUS_URL}" >/dev/null 2>&1
102
+ }
103
+
104
+ kill_iproxy_for_udid() {
105
+ pkill -f "iproxy.*-u[[:space:]]+${UDID}" >/dev/null 2>&1 || true
106
+ pkill -f "iproxy.*${UDID}" >/dev/null 2>&1 || true
107
+ }
108
+
109
+ kill_xcodebuild_for_udid() {
110
+ pkill -f "xcodebuild.*id=${UDID}" >/dev/null 2>&1 || true
111
+ }
112
+
113
+ xcodebuild_is_running_for_udid() {
114
+ pgrep -f "xcodebuild.*id=${UDID}" >/dev/null 2>&1
115
+ }
116
+
117
+ start_iproxy() {
118
+ if device_is_simulator; then
119
+ echo "[start-ios-wda] simulator mode, WDA binds directly to port ${WDA_PORT} (USE_PORT) — no forwarding needed"
120
+ return 0
121
+ fi
122
+ echo "[start-ios-wda] starting iproxy: 127.0.0.1:${WDA_PORT}->device:8100, 127.0.0.1:${MJPEG_LOCAL_PORT}->device:9100 (MJPEG)"
123
+ nohup iproxy -u "${UDID}" "${WDA_PORT}:8100" "${MJPEG_LOCAL_PORT}:9100" >>"${IPROXY_LOG_FILE}" 2>&1 &
124
+ }
125
+
126
+ # Modify .xctestrun plist to inject environment variables into the test process.
127
+ # This is the only reliable way to set env vars (USE_PORT, MJPEG_SERVER_PORT)
128
+ # for simulator test runners, because testmanagerd does NOT forward macOS env vars
129
+ # into the simulator's iOS runtime.
130
+ # Find the test-bundle key (top-level key excluding __xctestrun_metadata__) in xctestrun.
131
+ # Recent Xcode uses flat <bundleName> as key instead of TestConfigurations array.
132
+ get_xctestrun_bundle_key() {
133
+ local xctestrun="$1"
134
+ plutil -p "${xctestrun}" 2>/dev/null | grep '^ "' | grep -v '__xctestrun_metadata__' | head -1 | sed 's/^ "\(.*\)" =>.*/\1/'
135
+ }
136
+
137
+ inject_env_vars_into_xctestrun() {
138
+ local xctestrun="$1"
139
+ local port="$2"
140
+ local mjpeg_port="$3"
141
+ local bundle_key
142
+
143
+ bundle_key=$(get_xctestrun_bundle_key "${xctestrun}")
144
+ if [[ -z "${bundle_key}" ]]; then
145
+ echo "[start-ios-wda] could not determine bundle key in xctestrun"
146
+ return 1
147
+ fi
148
+ echo "[start-ios-wda] xctestrun bundle key: ${bundle_key}"
149
+
150
+ # Method 1: Inject USE_PORT / MJPEG_SERVER_PORT into EnvironmentVariables dict
151
+ # This is consumed by FBConfiguration's NSProcessInfo.processInfo.environment check.
152
+ local env_path="${bundle_key}.EnvironmentVariables.USE_PORT"
153
+ echo "[start-ios-wda] injecting USE_PORT=${port} at ${env_path}"
154
+ if plutil -insert "${env_path}" -string "${port}" "${xctestrun}" 2>/dev/null; then
155
+ echo "[start-ios-wda] injected USE_PORT"
156
+ else
157
+ # Key may already exist — replace instead
158
+ plutil -replace "${env_path}" -string "${port}" "${xctestrun}" 2>/dev/null && \
159
+ echo "[start-ios-wda] replaced USE_PORT" || \
160
+ echo "[start-ios-wda] WARNING: could not set USE_PORT"
161
+ fi
162
+
163
+ plutil -insert "${bundle_key}.EnvironmentVariables.MJPEG_SERVER_PORT" \
164
+ -string "${mjpeg_port}" "${xctestrun}" 2>/dev/null || \
165
+ plutil -replace "${bundle_key}.EnvironmentVariables.MJPEG_SERVER_PORT" \
166
+ -string "${mjpeg_port}" "${xctestrun}" 2>/dev/null || true
167
+
168
+ # Method 2: Inject --port into CommandLineArguments (HIGHEST priority in FBConfiguration:
169
+ # bindingPortRangeFromArguments reads NSProcessInfo.processInfo.arguments for "--port").
170
+ local cla_path="${bundle_key}.CommandLineArguments"
171
+ echo "[start-ios-wda] injecting --port ${port} into CommandLineArguments"
172
+ if plutil -replace "${cla_path}" \
173
+ -json "[\"--port\",\"${port}\",\"--mjpeg-server-port\",\"${mjpeg_port}\"]" \
174
+ "${xctestrun}" 2>/dev/null; then
175
+ echo "[start-ios-wda] injected --port via CommandLineArguments"
176
+ else
177
+ echo "[start-ios-wda] WARNING: could not set CommandLineArguments"
178
+ fi
179
+ }
180
+
181
+ start_xcodebuild() {
182
+ echo "[start-ios-wda] starting xcodebuild for WDA"
183
+
184
+ if device_is_simulator; then
185
+ echo "[start-ios-wda] simulator mode: using build-for-testing + xctestrun approach"
186
+ echo "[start-ios-wda] (macOS env vars do NOT propagate into simulator test processes)"
187
+
188
+ # Step 1: Build the test bundle and generate .xctestrun (blocking)
189
+ xcodebuild build-for-testing \
190
+ -project "${WDA_PROJECT}" \
191
+ -scheme "${WDA_SCHEME}" \
192
+ -destination "id=${UDID}" \
193
+ -derivedDataPath "${WDA_DERIVED_DATA}" \
194
+ >>"${WDA_LOG_FILE}" 2>&1
195
+
196
+ BUILD_RESULT=$?
197
+ if [[ "${BUILD_RESULT}" -ne 0 ]]; then
198
+ echo "[start-ios-wda] build-for-testing failed (exit=${BUILD_RESULT})"
199
+ echo "[start-ios-wda] check the log for details: ${WDA_LOG_FILE}"
200
+ return 1
201
+ fi
202
+
203
+ # Step 2: Find the .xctestrun file in derived data
204
+ XCTESTRUN=$(find "${WDA_DERIVED_DATA}" -name "*.xctestrun" -print -quit 2>/dev/null)
205
+ if [[ -z "${XCTESTRUN}" ]]; then
206
+ echo "[start-ios-wda] .xctestrun not found in ${WDA_DERIVED_DATA}"
207
+ echo "[start-ios-wda] falling back to env-var approach (may not work)"
208
+ USE_PORT="${WDA_PORT}" MJPEG_SERVER_PORT="${MJPEG_LOCAL_PORT}" \
209
+ nohup xcodebuild \
210
+ -project "${WDA_PROJECT}" \
211
+ -scheme "${WDA_SCHEME}" \
212
+ -destination "id=${UDID}" \
213
+ test >>"${WDA_LOG_FILE}" 2>&1 &
214
+ else
215
+ # Step 3: Inject environment variables into the xctestrun plist
216
+ inject_env_vars_into_xctestrun "${XCTESTRUN}" "${WDA_PORT}" "${MJPEG_LOCAL_PORT}"
217
+
218
+ echo "[start-ios-wda] modified xctestrun: ${XCTESTRUN}"
219
+
220
+ # Step 4: Run with modified xctestrun (skip rebuild, background)
221
+ nohup xcodebuild test-without-building \
222
+ -xctestrun "${XCTESTRUN}" \
223
+ -destination "id=${UDID}" \
224
+ -derivedDataPath "${WDA_DERIVED_DATA}" \
225
+ >>"${WDA_LOG_FILE}" 2>&1 &
226
+ fi
227
+ else
228
+ echo "[start-ios-wda] real-device mode: iproxy handles port forwarding, starting xcodebuild"
229
+ nohup xcodebuild \
230
+ -project "${WDA_PROJECT}" \
231
+ -scheme "${WDA_SCHEME}" \
232
+ -destination "id=${UDID}" \
233
+ test >>"${WDA_LOG_FILE}" 2>&1 &
234
+ fi
235
+
236
+ XCODEBUILD_PID=$!
237
+ }
238
+
239
+ wait_for_wda() {
240
+ local timeout_seconds="${1:-90}"
241
+
242
+ echo "[start-ios-wda] waiting for WDA status: ${WDA_STATUS_URL}"
243
+
244
+ for _ in $(seq 1 "${timeout_seconds}"); do
245
+ if ! device_is_online; then
246
+ echo "[start-ios-wda] device disconnected while starting: ${UDID}"
247
+ exit "${EXIT_DEVICE_NOT_CONNECTED}"
248
+ fi
249
+
250
+ if wda_is_healthy; then
251
+ echo "[start-ios-wda] wda ready: ${WDA_STATUS_URL}"
252
+ echo "[start-ios-wda] mjpeg_url=http://127.0.0.1:${MJPEG_LOCAL_PORT}/ (local -> device:9100)"
253
+ echo "[start-ios-wda] wda_log=${WDA_LOG_FILE}"
254
+ echo "[start-ios-wda] iproxy_log=${IPROXY_LOG_FILE}"
255
+ return 0
256
+ fi
257
+
258
+ sleep 1
259
+ done
260
+
261
+ return 1
262
+ }
263
+
264
+ echo "[start-ios-wda] checking device online: ${UDID}"
265
+
266
+ if ! device_is_online; then
267
+ echo "[start-ios-wda] device not connected or not online: ${UDID}"
268
+ echo "[start-ios-wda] only devices under '== Devices ==' are considered online"
269
+ exit "${EXIT_DEVICE_NOT_CONNECTED}"
270
+ fi
271
+
272
+ # Acquire exclusive lock to prevent concurrent watchdog instances from racing.
273
+ if ! try_acquire_lock; then
274
+ exit 0
275
+ fi
276
+
277
+ # Case 1: requested local port is already healthy.
278
+ if wda_is_healthy; then
279
+ echo "[start-ios-wda] wda already healthy on requested port: ${WDA_STATUS_URL}"
280
+ exit 0
281
+ fi
282
+
283
+ # Case 2: status is unhealthy. Check whether xcodebuild is already running.
284
+ if xcodebuild_is_running_for_udid; then
285
+ echo "[start-ios-wda] xcodebuild already running for udid=${UDID}, waiting for WDA on port ${WDA_PORT}"
286
+
287
+ if wait_for_wda 60; then
288
+ echo "[start-ios-wda] WDA became healthy on port ${WDA_PORT}"
289
+ exit 0
290
+ fi
291
+
292
+ # xcodebuild is running but WDA not healthy after waiting.
293
+ # Could be still building, or WDA is on a different port.
294
+ # Don't kill xcodebuild — let the next poll cycle retry.
295
+ echo "[start-ios-wda] xcodebuild running but WDA not healthy on ${WDA_PORT} after 60s, will retry"
296
+ exit 1
297
+ fi
298
+
299
+ # Case 3: no running xcodebuild — start fresh.
300
+ echo "[start-ios-wda] no existing xcodebuild for udid, starting xcodebuild"
301
+ if ! start_xcodebuild; then
302
+ echo "[start-ios-wda] start_xcodebuild failed, aborting"
303
+ echo "[start-ios-wda] check the log: ${WDA_LOG_FILE}"
304
+ tail -n 40 "${WDA_LOG_FILE}" 2>/dev/null || true
305
+ exit "${EXIT_WDA_START_FAILED}"
306
+ fi
307
+ start_iproxy
308
+
309
+ if wait_for_wda 90; then
310
+ echo "[start-ios-wda] started udid=${UDID} wda_local=${WDA_PORT} mjpeg_local=${MJPEG_LOCAL_PORT}"
311
+ if [[ -n "${XCODEBUILD_PID:-}" ]]; then
312
+ echo "[start-ios-wda] xcodebuild_pid=${XCODEBUILD_PID}"
313
+ fi
314
+ exit 0
315
+ fi
316
+
317
+ echo "[start-ios-wda] failed to start WDA within timeout"
318
+ echo "[start-ios-wda] status_url=${WDA_STATUS_URL}"
319
+ echo "[start-ios-wda] wda_log=${WDA_LOG_FILE}"
320
+ echo "[start-ios-wda] iproxy_log=${IPROXY_LOG_FILE}"
321
+
322
+ echo "[start-ios-wda] last WDA logs:"
323
+ tail -n 80 "${WDA_LOG_FILE}" 2>/dev/null || true
324
+
325
+ echo "[start-ios-wda] last iproxy logs:"
326
+ tail -n 40 "${IPROXY_LOG_FILE}" 2>/dev/null || true
327
+
328
+ exit "${EXIT_WDA_START_FAILED}"
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ stop_all_by_name() {
5
+ local process_name="$1"
6
+ local -a pids=()
7
+ local -a remaining=()
8
+
9
+ # macOS built-in bash (3.2) has no mapfile; collect pids via read loop.
10
+ while IFS= read -r pid; do
11
+ if [[ -n "${pid}" ]]; then
12
+ pids+=("${pid}")
13
+ fi
14
+ done < <(pgrep -x "${process_name}" || true)
15
+
16
+ if [[ "${#pids[@]}" -eq 0 ]]; then
17
+ echo "[stop-ios-wda] no ${process_name} process found"
18
+ return 0
19
+ fi
20
+
21
+ echo "[stop-ios-wda] stopping ${process_name} pids: ${pids[*]}"
22
+ kill "${pids[@]}" 2>/dev/null || true
23
+ sleep 1
24
+
25
+ for pid in "${pids[@]}"; do
26
+ if kill -0 "${pid}" >/dev/null 2>&1; then
27
+ remaining+=("${pid}")
28
+ fi
29
+ done
30
+
31
+ if [[ "${#remaining[@]}" -gt 0 ]]; then
32
+ echo "[stop-ios-wda] force killing ${process_name} pids: ${remaining[*]}"
33
+ kill -9 "${remaining[@]}" 2>/dev/null || true
34
+ fi
35
+
36
+ echo "[stop-ios-wda] ${process_name} cleaned"
37
+ }
38
+
39
+ echo "[stop-ios-wda] cleanup started"
40
+ stop_all_by_name "iproxy"
41
+ stop_all_by_name "xcodebuild"
42
+ # Clean up simulator WDA Node.js TCP forwarding (pattern: connect(8100, '127.0.0.1'))
43
+ pkill -f "node.*connect.8100.*127.0.0.1" 2>/dev/null || true
44
+ echo "[stop-ios-wda] cleanup finished"