@unotest/mobile 0.1.1 → 0.8.0

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.
@@ -17,7 +17,7 @@ __export(pg_client_exports, {
17
17
  async function create(databaseUrl) {
18
18
  const pgMod = await import("pg").catch(() => {
19
19
  throw new Error(
20
- `'pg' driver requested by DATABASE_URL but the 'pg' package is not installed. Run: pnpm add -D pg @types/pg`
20
+ `'pg' driver requested by DATABASE_URL but the 'pg' package is not installed. Install it (npm i -D pg @types/pg, or your package manager's equivalent).`
21
21
  );
22
22
  });
23
23
  const PoolCtor = pgMod.default?.Pool ?? pgMod.Pool;
@@ -66,7 +66,7 @@ function isWriteSql(sql) {
66
66
  async function create2(databaseUrl) {
67
67
  const mod = await import("mysql2/promise").catch(() => {
68
68
  throw new Error(
69
- `'mysql' driver requested by DATABASE_URL but the 'mysql2' package is not installed. Run: pnpm add -D mysql2`
69
+ `'mysql' driver requested by DATABASE_URL but the 'mysql2' package is not installed. Install it (npm i -D mysql2, or your package manager's equivalent).`
70
70
  );
71
71
  });
72
72
  const pool = mod.createPool(databaseUrl);
@@ -116,7 +116,7 @@ function parseSqlitePath(databaseUrl) {
116
116
  async function create3(databaseUrl) {
117
117
  const mod = await import("better-sqlite3").catch(() => {
118
118
  throw new Error(
119
- `'sqlite' driver requested by DATABASE_URL but the 'better-sqlite3' package is not installed. Run: pnpm add -D better-sqlite3 @types/better-sqlite3`
119
+ `'sqlite' driver requested by DATABASE_URL but the 'better-sqlite3' package is not installed. Install it (npm i -D better-sqlite3 @types/better-sqlite3, or your package manager's equivalent).`
120
120
  );
121
121
  });
122
122
  const Database = mod.default ?? mod;
@@ -174,33 +174,65 @@ function ensureLoaded() {
174
174
  }
175
175
  __name(ensureLoaded, "ensureLoaded");
176
176
  var EnvSchema = z.object({
177
- APP_BUNDLE_ID: z.string().min(1),
178
- APP_URL_SCHEME: z.string().min(1),
179
- INVITE_DEEPLINK_PREFIX: z.string().min(1),
180
- API_BASE_URL: z.string().url(),
177
+ // APP_BUNDLE_ID — required at appLaunch / install time. We let the schema
178
+ // accept it as optional so commands that don't touch the app (`doctor`,
179
+ // `lint`) work without it. Use-site (WdaDriver) validates and errors with
180
+ // a clear message if missing.
181
+ APP_BUNDLE_ID: z.string().min(1).optional(),
182
+ // APP_URL_SCHEME — only consumed by the (currently unimplemented) Expo
183
+ // dev-client recovery flow. Optional; reserved for future use.
184
+ APP_URL_SCHEME: z.string().min(1).optional(),
185
+ // APP_PERMISSIONS — comma-separated `simctl privacy` services that
186
+ // `install --clean` (CLI + MCP) auto-grants before launch. Populated
187
+ // by `install --update-env` from detected NS*UsageDescription keys in
188
+ // the .app's Info.plist (P4 / S4). Optional — apps that don't request
189
+ // privacy services leave this unset.
190
+ // Example: APP_PERMISSIONS=location,motion
191
+ APP_PERMISSIONS: z.string().optional(),
192
+ // API_BASE_URL — only required if scenarios call `apiCall(...)`. Lazy:
193
+ // the ApiClient is constructed at first use, not at startup.
194
+ API_BASE_URL: z.string().url().optional(),
181
195
  // PROJECT_ROOT — optional default cwd for the `shell(...)` DSL primitive.
182
196
  // When unset, shell commands run from process.cwd(). Set to the absolute
183
197
  // path of the project-under-test when its CLI must be invoked from a
184
- // specific directory (e.g. monorepo root for `pnpm --filter ...`).
198
+ // specific directory (e.g. monorepo root).
185
199
  PROJECT_ROOT: z.string().optional(),
186
- // DATABASE_URL — connection string for native pg/mysql/sqlite client
187
- // (Stage 0 plugin DbClient). Format examples:
200
+ // DATABASE_URL — only required if scenarios call `dbQuery(...)` /
201
+ // `dbExec(...)`. Lazy: the DbClient is constructed at first use, not at
202
+ // startup. Format examples:
188
203
  // postgresql://user:pass@host:5432/dbname
189
204
  // mysql://user:pass@host:3306/dbname
190
205
  // sqlite:./e2e.db
191
206
  // sqlite::memory:
192
- DATABASE_URL: z.string().min(1),
193
- SIM_A_NAME: z.string().min(1),
194
- SIM_B_NAME: z.string().min(1),
207
+ DATABASE_URL: z.string().min(1).optional(),
208
+ // SIM_A_NAME / SIM_B_NAME — schema-optional so non-UI commands work
209
+ // without them. Pool-aware validation in loadEnv() below requires the
210
+ // names for slots actually present in SIM_POOL.
211
+ SIM_A_NAME: z.string().min(1).optional(),
212
+ SIM_B_NAME: z.string().min(1).optional(),
195
213
  SIM_POOL: z.string().default("A,B"),
196
- METRO_URL: z.string().url(),
214
+ // METRO_URL — only consumed by the (currently unimplemented) Expo
215
+ // dev-client recovery flow. Optional; reserved for future use.
216
+ METRO_URL: z.string().url().optional(),
197
217
  // Expo dev-client builds show a "Development Servers" launcher after a clean
198
- // launch (clearState wipes the remembered Metro URL). When true, the driver
199
- // auto-opens `exp+<APP_URL_SCHEME>://expo-development-client/?url=<METRO_URL>`
200
- // after `app_launch clean: true` to bypass the launcher.
218
+ // launch. When true, the driver auto-opens
219
+ // `exp+<APP_URL_SCHEME>://expo-development-client/?url=<METRO_URL>` after
220
+ // `app_launch clean: true` to bypass the launcher. (Flow not yet wired.)
201
221
  EXPO_DEV_CLIENT: z.string().optional().transform((v) => v === "true" || v === "1"),
202
- SESSION_LOG_PATH: z.string().default("sessions/current.jsonl"),
203
- ARTIFACTS_DIR: z.string().default("artifacts"),
222
+ SESSION_LOG_PATH: z.string().default("unotest/sessions/current.jsonl"),
223
+ // Explicit kill-switch for session recording. When "1"/"true", or when
224
+ // SESSION_LOG_PATH is empty, buildApp wires a NoopSessionRecorder. Used
225
+ // by evals harness and any consumer that wants the MCP server to make
226
+ // no on-disk session log.
227
+ SESSION_LOG_DISABLE: z.string().optional().transform((v) => v === "1" || v === "true"),
228
+ // When true, recorder writes the FULL tool result alongside the
229
+ // truncated preview. Off by default — snapshots can be megabytes.
230
+ SESSION_LOG_FULL: z.string().optional().transform((v) => v === "1" || v === "true"),
231
+ ARTIFACTS_DIR: z.string().default("unotest/artifacts"),
232
+ // Where ExplorationService persists per-session JSONL recording logs.
233
+ // Default: <ARTIFACTS_DIR>/explorations. Folded into the gitignored
234
+ // `unotest/artifacts/` tree by the init template.
235
+ EXPLORATIONS_DIR: z.string().optional(),
204
236
  // WDA per-slot port mapping (D-13 parallel multi-device). Stored as a
205
237
  // comma-separated `slot=port` list, e.g. "A=8100,B=8101". Each slot present
206
238
  // in SIM_POOL needs a port.
@@ -231,7 +263,7 @@ ${issues}`
231
263
  const value = process.env[key];
232
264
  if (!value) {
233
265
  throw new Error(
234
- `SIM_POOL lists slot "${slot}" but ${key} is not set in unotest/.env`
266
+ `SIM_POOL lists slot "${slot}" but ${key} is not set in unotest/.env. Either add ${key}=<simulator-name> or shrink SIM_POOL to omit slot "${slot}".`
235
267
  );
236
268
  }
237
269
  simBySlot[slot] = value;
@@ -262,7 +294,8 @@ ${issues}`
262
294
  wdaPortBySlot,
263
295
  defaultActionWaitMs: raw.WDA_DEFAULT_ACTION_WAIT_MS,
264
296
  defaultWaitForTimeoutMs: raw.WDA_DEFAULT_WAITFOR_TIMEOUT_MS,
265
- pausedRuntimeTtlMs: raw.PAUSED_RUNTIME_TTL_MS
297
+ pausedRuntimeTtlMs: raw.PAUSED_RUNTIME_TTL_MS,
298
+ explorationsDir: raw.EXPLORATIONS_DIR ?? `${raw.ARTIFACTS_DIR}/explorations`
266
299
  };
267
300
  return cached;
268
301
  }
@@ -401,27 +434,84 @@ async function listSimulators() {
401
434
  return out;
402
435
  }
403
436
  __name(listSimulators, "listSimulators");
404
- async function resolveSimByName(name) {
437
+ function friendlyRuntime(runtime) {
438
+ const m = runtime.match(/SimRuntime\.([A-Za-z]+)-(\d+)-(\d+)$/);
439
+ if (!m) return runtime;
440
+ return `${m[1]} ${m[2]}.${m[3]}`;
441
+ }
442
+ __name(friendlyRuntime, "friendlyRuntime");
443
+ async function resolveSimByName(spec) {
444
+ const { name, runtimeHint } = parseSimSpec(spec);
405
445
  const all = await listSimulators();
406
- const matches = all.filter((s) => s.name === name);
446
+ let matches = all.filter((s) => s.name === name);
447
+ if (runtimeHint) {
448
+ matches = matches.filter((s) => friendlyRuntime(s.runtime).toLowerCase().includes(runtimeHint.toLowerCase()));
449
+ }
407
450
  if (matches.length === 0) {
408
- const available = all.map((s) => s.name).join(", ") || "(none)";
409
- throw new Error(`Sim "${name}" not found. Available: ${available}`);
451
+ const available = all.map((s) => `${s.name} @ ${friendlyRuntime(s.runtime)}`).join(", ") || "(none)";
452
+ throw new Error(
453
+ `Sim "${spec}" not found. Available: ${available}. If you have multiple sims with the same name, disambiguate via "<name> @ <runtime>" (e.g. "iPhone 16 @ iOS 17.5").`
454
+ );
410
455
  }
411
456
  const booted = matches.find((s) => s.state === "Booted");
412
457
  return booted ?? matches[0];
413
458
  }
414
459
  __name(resolveSimByName, "resolveSimByName");
460
+ function parseSimSpec(spec) {
461
+ const idx = spec.lastIndexOf("@");
462
+ if (idx === -1) return { name: spec.trim() };
463
+ return {
464
+ name: spec.slice(0, idx).trim(),
465
+ runtimeHint: spec.slice(idx + 1).trim()
466
+ };
467
+ }
468
+ __name(parseSimSpec, "parseSimSpec");
415
469
  async function bootSim(udid) {
416
470
  try {
417
471
  await exec2("xcrun", ["simctl", "boot", udid]);
472
+ if (process.env.SIMCTL_HEADLESS !== "1") await openSimulatorApp();
473
+ return;
418
474
  } catch (e) {
419
475
  const msg = e.stderr ?? String(e);
420
- if (msg.includes("Booted") || msg.includes("current state: Booted")) return;
421
- throw e;
476
+ if (msg.includes("Booted") || msg.includes("current state: Booted")) {
477
+ if (process.env.SIMCTL_HEADLESS !== "1") await openSimulatorApp();
478
+ return;
479
+ }
480
+ const state = await currentSimState(udid).catch(() => null);
481
+ if (state === "Booted") {
482
+ if (process.env.SIMCTL_HEADLESS !== "1") await openSimulatorApp();
483
+ return;
484
+ }
485
+ if (state && state !== "Shutdown") {
486
+ throw new Error(
487
+ `simctl boot ${udid} failed and sim is in transitional state "${state}". Wait a few seconds and retry, or force-shutdown: \`xcrun simctl shutdown ${udid}\`.
488
+ Original error: ${msg.trim().split("\n").slice(0, 4).join(" | ")}`
489
+ );
490
+ }
491
+ throw new Error(
492
+ `simctl boot ${udid} failed. Sim is "${state ?? "unknown"}".
493
+ Common fixes:
494
+ \u2022 Open Simulator.app, pick this device manually, ensure it boots.
495
+ \u2022 Erase: \`xcrun simctl erase ${udid}\` (wipes content & settings).
496
+ \u2022 Restart CoreSimulator: \`sudo killall -9 com.apple.CoreSimulator.CoreSimulatorService\`.
497
+ Original error: ${msg.trim().split("\n").slice(0, 4).join(" | ")}`
498
+ );
422
499
  }
423
500
  }
424
501
  __name(bootSim, "bootSim");
502
+ async function currentSimState(udid) {
503
+ const sims = await listSimulators();
504
+ const sim = sims.find((s) => s.udid === udid);
505
+ return sim ? sim.state : null;
506
+ }
507
+ __name(currentSimState, "currentSimState");
508
+ async function openSimulatorApp() {
509
+ try {
510
+ await exec2("open", ["-a", "Simulator"]);
511
+ } catch {
512
+ }
513
+ }
514
+ __name(openSimulatorApp, "openSimulatorApp");
425
515
  async function shutdownSim(udid) {
426
516
  try {
427
517
  await exec2("xcrun", ["simctl", "shutdown", udid]);
@@ -449,9 +539,37 @@ async function terminateApp(udid, bundleId) {
449
539
  }
450
540
  __name(terminateApp, "terminateApp");
451
541
  async function launchApp(udid, bundleId, args = []) {
452
- await exec2("xcrun", ["simctl", "launch", udid, bundleId, ...args]);
542
+ const { stdout } = await exec2("xcrun", ["simctl", "launch", udid, bundleId, ...args]);
543
+ const m = stdout.match(/:\s*(\d+)/);
544
+ const pid = m ? Number.parseInt(m[1], 10) : 0;
545
+ return { pid };
453
546
  }
454
547
  __name(launchApp, "launchApp");
548
+ async function isPidAlive(pid) {
549
+ if (pid <= 0) return false;
550
+ try {
551
+ process.kill(pid, 0);
552
+ return true;
553
+ } catch {
554
+ return false;
555
+ }
556
+ }
557
+ __name(isPidAlive, "isPidAlive");
558
+ async function assertLaunchedAndStable(udid, bundleId, pid, options = {}) {
559
+ const settleMs = options.settleMs ?? 1500;
560
+ await new Promise((r) => setTimeout(r, settleMs));
561
+ const alive = await isPidAlive(pid);
562
+ if (alive) return;
563
+ const procName = bundleId.split(".").pop() ?? bundleId;
564
+ throw new Error(
565
+ `App "${bundleId}" started (PID ${pid}) but exited within ${settleMs}ms \u2014 looks like a crash on launch.
566
+ See the crash reason:
567
+ xcrun simctl spawn ${udid} log show --predicate 'process == "${procName}" OR senderImagePath CONTAINS "${bundleId}"' --last 30s --info
568
+ Or open Console.app, filter by your app name.
569
+ Common causes: missing native module (rebuild after changing native deps), JS bundle baked against wrong workspace/env, signing issues.`
570
+ );
571
+ }
572
+ __name(assertLaunchedAndStable, "assertLaunchedAndStable");
455
573
  async function uninstallApp(udid, bundleId) {
456
574
  try {
457
575
  await exec2("xcrun", ["simctl", "uninstall", udid, bundleId]);
@@ -463,6 +581,63 @@ async function installApp(udid, appPath) {
463
581
  await exec2("xcrun", ["simctl", "install", udid, appPath]);
464
582
  }
465
583
  __name(installApp, "installApp");
584
+ async function isAppInstalled(udid, bundleId) {
585
+ try {
586
+ await exec2("xcrun", ["simctl", "get_app_container", udid, bundleId]);
587
+ return true;
588
+ } catch {
589
+ return false;
590
+ }
591
+ }
592
+ __name(isAppInstalled, "isAppInstalled");
593
+ async function eraseSim(udid) {
594
+ await exec2("xcrun", ["simctl", "erase", udid]);
595
+ }
596
+ __name(eraseSim, "eraseSim");
597
+ async function keychainResetSim(udid) {
598
+ await exec2("xcrun", ["simctl", "keychain", udid, "reset"]);
599
+ }
600
+ __name(keychainResetSim, "keychainResetSim");
601
+ async function privacyGrantSim(udid, service, bundleId) {
602
+ await exec2("xcrun", ["simctl", "privacy", udid, "grant", service, bundleId]);
603
+ }
604
+ __name(privacyGrantSim, "privacyGrantSim");
605
+ async function pinEnglishKeyboardSim(udid) {
606
+ await exec2("xcrun", [
607
+ "simctl",
608
+ "spawn",
609
+ udid,
610
+ "defaults",
611
+ "write",
612
+ "-g",
613
+ "AppleKeyboards",
614
+ "-array",
615
+ "en_US@hw=US;sw=QWERTY"
616
+ ]);
617
+ await exec2("xcrun", [
618
+ "simctl",
619
+ "spawn",
620
+ udid,
621
+ "defaults",
622
+ "write",
623
+ "-g",
624
+ "AppleLanguages",
625
+ "-array",
626
+ "en"
627
+ ]);
628
+ await exec2("xcrun", [
629
+ "simctl",
630
+ "spawn",
631
+ udid,
632
+ "defaults",
633
+ "write",
634
+ "-g",
635
+ "AppleLocale",
636
+ "-string",
637
+ "en_US"
638
+ ]);
639
+ }
640
+ __name(pinEnglishKeyboardSim, "pinEnglishKeyboardSim");
466
641
 
467
642
  // src/driver/simctl/adapter.ts
468
643
  var SimctlAdapter = class {
@@ -487,6 +662,9 @@ var SimctlAdapter = class {
487
662
  async launch(udid, bundleId, args) {
488
663
  return launchApp(udid, bundleId, args);
489
664
  }
665
+ async assertLaunchedAndStable(udid, bundleId, pid, settleMs) {
666
+ return assertLaunchedAndStable(udid, bundleId, pid, settleMs !== void 0 ? { settleMs } : {});
667
+ }
490
668
  async terminate(udid, bundleId) {
491
669
  return terminateApp(udid, bundleId);
492
670
  }
@@ -496,12 +674,51 @@ var SimctlAdapter = class {
496
674
  async screenshot(udid) {
497
675
  return screenshotPng(udid);
498
676
  }
677
+ async isInstalled(udid, bundleId) {
678
+ return isAppInstalled(udid, bundleId);
679
+ }
680
+ async erase(udid) {
681
+ return eraseSim(udid);
682
+ }
683
+ /** B5 — wipes simulator keychain so auth tokens don't survive `clean`
684
+ * launches. Used by `installApp({clean})` and `WdaDriver.appLaunch({clean})`. */
685
+ async keychainReset(udid) {
686
+ return keychainResetSim(udid);
687
+ }
688
+ /** S4 — pre-grant an iOS privacy service to a bundle so the app skips
689
+ * the SpringBoard permission dialog on first launch. */
690
+ async privacyGrant(udid, service, bundleId) {
691
+ return privacyGrantSim(udid, service, bundleId);
692
+ }
693
+ /** S8 — pin the sim's keyboard to en_US@QWERTY so WDA's typeText
694
+ * doesn't drop Latin chars when the sim was last on a Cyrillic layout. */
695
+ async pinEnglishKeyboard(udid) {
696
+ return pinEnglishKeyboardSim(udid);
697
+ }
698
+ async openSimulatorApp() {
699
+ return openSimulatorApp();
700
+ }
499
701
  };
500
702
 
501
- // src/driver/wda/session.ts
502
- import { setTimeout as delay } from "timers/promises";
503
-
504
703
  // src/driver/wda/http-client.ts
704
+ var NoAlertPresentError = class extends Error {
705
+ static {
706
+ __name(this, "NoAlertPresentError");
707
+ }
708
+ constructor(message) {
709
+ super(message);
710
+ this.name = "NoAlertPresentError";
711
+ }
712
+ };
713
+ var WdaSessionGoneError = class extends Error {
714
+ static {
715
+ __name(this, "WdaSessionGoneError");
716
+ }
717
+ constructor(message) {
718
+ super(message);
719
+ this.name = "WdaSessionGoneError";
720
+ }
721
+ };
505
722
  var WdaHttpClient = class {
506
723
  static {
507
724
  __name(this, "WdaHttpClient");
@@ -578,6 +795,39 @@ var WdaHttpClient = class {
578
795
  async openUrl(sessionId, url) {
579
796
  await this.post(`/session/${encodeURIComponent(sessionId)}/url`, { url });
580
797
  }
798
+ // ---- Alerts (native SpringBoard) ---------------------------------------
799
+ //
800
+ // These hit WDA's /alert/* endpoints which talk to the *system* alert
801
+ // hierarchy (SpringBoard process), NOT the app's a11y tree. Use for
802
+ // UIAlertController-backed dialogs: permission prompts, ATT, Sign Out
803
+ // confirms, iOS update banners. App-level Modals belong to regular tap.
804
+ //
805
+ // `name` in WdaAcceptAlertRequest taps a specific button by label.
806
+ // Omitted = WDA's position-based fallback, which is kind-dependent (see
807
+ // FBAlert.m): UIAlertController accept = LAST button, dismiss = FIRST;
808
+ // action sheet is reversed. Pass `name` whenever the alert isn't a plain
809
+ // 1-button modal — it's the only label-stable path.
810
+ async acceptAlert(sessionId, req = {}) {
811
+ await mapNoAlert(
812
+ this.post(
813
+ `/session/${encodeURIComponent(sessionId)}/alert/accept`,
814
+ req.name ? { name: req.name } : {}
815
+ )
816
+ );
817
+ }
818
+ async dismissAlert(sessionId) {
819
+ await mapNoAlert(this.post(`/session/${encodeURIComponent(sessionId)}/alert/dismiss`, {}));
820
+ }
821
+ async alertText(sessionId) {
822
+ return mapNoAlert(
823
+ this.get(`/session/${encodeURIComponent(sessionId)}/alert/text`)
824
+ );
825
+ }
826
+ async alertButtons(sessionId) {
827
+ return mapNoAlert(
828
+ this.get(`/session/${encodeURIComponent(sessionId)}/alert/buttons`)
829
+ );
830
+ }
581
831
  // ---- internals ---------------------------------------------------------
582
832
  async get(path) {
583
833
  return this.request("GET", path);
@@ -597,12 +847,32 @@ var WdaHttpClient = class {
597
847
  const resp = await this.fetchFn(`${this.baseUrl}${path}`, init);
598
848
  if (!resp.ok) {
599
849
  const text = await resp.text().catch(() => "<no body>");
600
- throw new Error(`WDA ${method} ${path} failed: HTTP ${resp.status} \u2014 ${text.slice(0, 500)}`);
850
+ const snippet = text.slice(0, 500);
851
+ if (resp.status === 404 && /no such session|could not find session|invalid session id/i.test(snippet)) {
852
+ throw new WdaSessionGoneError(
853
+ `WDA ${method} ${path} reports session is gone: ${snippet}`
854
+ );
855
+ }
856
+ throw new Error(`WDA ${method} ${path} failed: HTTP ${resp.status} \u2014 ${snippet}`);
601
857
  }
602
858
  if (resp.status === 204) return void 0;
603
859
  return await resp.json();
604
860
  }
605
861
  };
862
+ async function mapNoAlert(p) {
863
+ try {
864
+ return await p;
865
+ } catch (err) {
866
+ const msg = err instanceof Error ? err.message : String(err);
867
+ if (/HTTP 404/.test(msg) && /alert/i.test(msg)) {
868
+ throw new NoAlertPresentError(
869
+ `No active iOS alert on this device. WDA: ${msg.slice(msg.indexOf("HTTP"))}`
870
+ );
871
+ }
872
+ throw err;
873
+ }
874
+ }
875
+ __name(mapNoAlert, "mapNoAlert");
606
876
  function extractElementId(h) {
607
877
  const id = h["element-6066-11e4-a52e-4f735466cecf"] ?? h.ELEMENT;
608
878
  if (!id) {
@@ -612,6 +882,9 @@ function extractElementId(h) {
612
882
  }
613
883
  __name(extractElementId, "extractElementId");
614
884
 
885
+ // src/driver/wda/session.ts
886
+ import { setTimeout as delay } from "timers/promises";
887
+
615
888
  // src/driver/wda/probe.ts
616
889
  async function tryProbeWda(port, timeoutMs, fetchFn = (input, init) => fetch(input, init)) {
617
890
  const ctrl = new AbortController();
@@ -687,13 +960,13 @@ var WdaRunner = class {
687
960
  async stop() {
688
961
  if (!this.child) return;
689
962
  const child = this.child;
690
- return new Promise((resolve4) => {
691
- child.once("exit", () => resolve4());
963
+ return new Promise((resolve6) => {
964
+ child.once("exit", () => resolve6());
692
965
  try {
693
966
  child.kill("SIGTERM");
694
967
  } catch (e) {
695
968
  this.logger.warn(`WdaRunner.stop: kill SIGTERM failed: ${e.message}`);
696
- resolve4();
969
+ resolve6();
697
970
  }
698
971
  setTimeout(() => {
699
972
  if (this.child) {
@@ -750,6 +1023,7 @@ var WdaSession = class {
750
1023
  });
751
1024
  this.sessionId = created.sessionId ?? created.value.sessionId;
752
1025
  this.deps.logger.debug(`wda session started: ${this.deps.slot} (${this.sessionId})`);
1026
+ await this.warmupTree();
753
1027
  } catch (e) {
754
1028
  if (this.runnerHandle) {
755
1029
  try {
@@ -784,6 +1058,45 @@ var WdaSession = class {
784
1058
  }
785
1059
  }
786
1060
  }
1061
+ /**
1062
+ * Warm up the accessibility tree after a fresh session by polling
1063
+ * `/source` until two consecutive readings agree on node count. iOS
1064
+ * XCTest's a11y discovery is asynchronous: the first `/source` after
1065
+ * createSession can return a partial tree (often only the application
1066
+ * root) for ~150-300ms while attributes are still being populated. A
1067
+ * caller's first `a11yTree()` would then see that partial state and
1068
+ * the agent acts on a half-built screen. Caps at 4 attempts so a
1069
+ * truly animating screen (rare at start time) doesn't hang the boot.
1070
+ */
1071
+ async warmupTree() {
1072
+ if (this.deps.skipWarmup) return;
1073
+ const delayMs = this.deps.warmupDelayMs ?? 150;
1074
+ let prevCount = -1;
1075
+ for (let i = 0; i < 4; i++) {
1076
+ let count = 0;
1077
+ try {
1078
+ const resp = await this.client.source(this.sessionId);
1079
+ count = countWdaSource(resp.value);
1080
+ } catch (e) {
1081
+ this.deps.logger.warn(
1082
+ `WdaSession[${this.deps.slot}] warmup /source attempt ${i + 1} failed: ${e.message}`
1083
+ );
1084
+ }
1085
+ if (i > 0 && count > 0 && count === prevCount) {
1086
+ this.deps.logger.debug(
1087
+ `WdaSession[${this.deps.slot}] warmup settled at attempt ${i + 1} (${count} nodes)`
1088
+ );
1089
+ return;
1090
+ }
1091
+ prevCount = count;
1092
+ if (i < 3 && delayMs > 0) {
1093
+ await new Promise((r) => setTimeout(r, delayMs));
1094
+ }
1095
+ }
1096
+ this.deps.logger.warn(
1097
+ `WdaSession[${this.deps.slot}] warmup did not settle in 4 attempts \u2014 proceeding with possibly-partial tree`
1098
+ );
1099
+ }
787
1100
  async waitUntilReady(timeoutMs) {
788
1101
  const deadline = Date.now() + timeoutMs;
789
1102
  let lastErr;
@@ -801,6 +1114,13 @@ var WdaSession = class {
801
1114
  );
802
1115
  }
803
1116
  };
1117
+ function countWdaSource(n) {
1118
+ if (!n || typeof n !== "object") return 0;
1119
+ let total = 1;
1120
+ for (const c of n.children ?? []) total += countWdaSource(c);
1121
+ return total;
1122
+ }
1123
+ __name(countWdaSource, "countWdaSource");
804
1124
  async function defaultStartWdaRunner(handle, port, binaryProvider, simctl, logger) {
805
1125
  const probe = await tryProbeWda(port, 1500);
806
1126
  if (probe?.ready) {
@@ -835,7 +1155,7 @@ function mapNode(raw) {
835
1155
  if (name) out.testId = name;
836
1156
  if (text) out.text = text;
837
1157
  if (labelOut) out.label = labelOut;
838
- if (raw.type) out.role = raw.type;
1158
+ if (raw.type) out.role = shortRole(raw.type);
839
1159
  const bounds = parseRect(raw.rect);
840
1160
  if (bounds) out.bounds = bounds;
841
1161
  const enabled = parseBool(raw.isEnabled);
@@ -845,6 +1165,11 @@ function mapNode(raw) {
845
1165
  return out;
846
1166
  }
847
1167
  __name(mapNode, "mapNode");
1168
+ function shortRole(t) {
1169
+ if (!t) return void 0;
1170
+ return t.replace(/^XCUIElementType/, "").toLowerCase();
1171
+ }
1172
+ __name(shortRole, "shortRole");
848
1173
  function nonEmpty(s) {
849
1174
  if (s == null) return void 0;
850
1175
  const trimmed = s.trim();
@@ -917,10 +1242,24 @@ var WdaDriver = class _WdaDriver {
917
1242
  async appLaunch(slot, opts) {
918
1243
  const h = await this.boot(slot);
919
1244
  const bundleId = opts.bundleId ?? this.deps.appBundleId;
1245
+ if (!bundleId) {
1246
+ throw new Error(
1247
+ `appLaunch needs a bundle id \u2014 set APP_BUNDLE_ID in unotest/.env or pass it explicitly.`
1248
+ );
1249
+ }
1250
+ const installed = await this.simctl.isInstalled(h.udid, bundleId);
1251
+ if (!installed) {
1252
+ throw new Error(
1253
+ `App "${bundleId}" is not installed on ${h.name} (slot ${slot}).
1254
+ Install it first: \`npx unotest-mobile install <path-to-.app>\` (or set APP_PATH in unotest/.env and run \`unotest-mobile install\`).`
1255
+ );
1256
+ }
920
1257
  if (opts.clean) {
921
1258
  await this.simctl.terminate(h.udid, bundleId);
1259
+ await this.simctl.keychainReset(h.udid);
922
1260
  }
923
- await this.simctl.launch(h.udid, bundleId);
1261
+ const { pid } = await this.simctl.launch(h.udid, bundleId);
1262
+ await this.simctl.assertLaunchedAndStable(h.udid, bundleId, pid);
924
1263
  }
925
1264
  async openDeeplink(slot, url) {
926
1265
  const h = await this.boot(slot);
@@ -928,53 +1267,59 @@ var WdaDriver = class _WdaDriver {
928
1267
  }
929
1268
  // ---- UiDriver ----------------------------------------------------------
930
1269
  async tap(slot, selector) {
931
- const session = await this.getSession(slot);
932
- const bounds = await this.resolveBounds(slot, selector);
933
- const { cx, cy } = center(bounds);
934
- await session.client.tap(session.getSessionId(), { x: cx, y: cy });
1270
+ await this.withFreshSession(slot, async (session) => {
1271
+ const bounds = await this.resolveBounds(slot, selector);
1272
+ const { cx, cy } = center(bounds);
1273
+ await session.client.tap(session.getSessionId(), { x: cx, y: cy });
1274
+ });
935
1275
  }
936
1276
  async type(slot, selector, text) {
937
- const session = await this.getSession(slot);
938
- const elementId = await this.resolveElementId(slot, selector);
939
- await session.client.setElementValue(session.getSessionId(), elementId, {
940
- value: Array.from(text)
1277
+ await this.withFreshSession(slot, async (session) => {
1278
+ const elementId = await this.resolveElementId(slot, selector);
1279
+ await session.client.setElementValue(session.getSessionId(), elementId, {
1280
+ value: Array.from(text)
1281
+ });
941
1282
  });
942
1283
  }
943
1284
  async swipe(slot, direction, from) {
944
- const session = await this.getSession(slot);
945
- const size = await session.client.windowSize(session.getSessionId());
946
- const w = size.value.width;
947
- const h = size.value.height;
948
- let fromX, fromY;
949
- if (from) {
950
- const b = await this.resolveBounds(slot, from);
951
- const c = center(b);
952
- fromX = c.cx;
953
- fromY = c.cy;
954
- } else {
955
- fromX = w / 2;
956
- fromY = h / 2;
957
- }
958
- const dist2 = Math.min(w, h) * 0.4;
959
- const toX = direction === "left" ? fromX - dist2 : direction === "right" ? fromX + dist2 : fromX;
960
- const toY = direction === "up" ? fromY - dist2 : direction === "down" ? fromY + dist2 : fromY;
961
- await session.client.drag(session.getSessionId(), { fromX, fromY, toX, toY, duration: 0.3 });
1285
+ await this.withFreshSession(slot, async (session) => {
1286
+ const size = await session.client.windowSize(session.getSessionId());
1287
+ const w = size.value.width;
1288
+ const h = size.value.height;
1289
+ let fromX, fromY;
1290
+ if (from) {
1291
+ const b = await this.resolveBounds(slot, from);
1292
+ const c = center(b);
1293
+ fromX = c.cx;
1294
+ fromY = c.cy;
1295
+ } else {
1296
+ fromX = w / 2;
1297
+ fromY = h / 2;
1298
+ }
1299
+ const dist2 = Math.min(w, h) * 0.4;
1300
+ const toX = direction === "left" ? fromX - dist2 : direction === "right" ? fromX + dist2 : fromX;
1301
+ const toY = direction === "up" ? fromY - dist2 : direction === "down" ? fromY + dist2 : fromY;
1302
+ await session.client.drag(session.getSessionId(), { fromX, fromY, toX, toY, duration: 0.3 });
1303
+ });
962
1304
  }
963
1305
  async pressKey(slot, key) {
964
1306
  const session = await this.getSession(slot);
965
- if (key === "home") {
966
- await session.client.pressKey(session.getSessionId(), "home");
967
- return;
968
- }
969
- if (key === "enter") {
970
- await session.client.typeText(session.getSessionId(), { value: ["\n"] });
971
- return;
972
- }
973
- if (key === "escape") {
974
- await session.client.typeText(session.getSessionId(), { value: ["\x1B"] });
975
- return;
1307
+ if (key !== "home" && key !== "enter" && key !== "escape") {
1308
+ throw new Error(
1309
+ "pressKey('back') is not supported on iOS - use swipe-back or app-specific control"
1310
+ );
976
1311
  }
977
- throw new Error(`pressKey('back') is not supported on iOS \u2014 use swipe-back or app-specific control`);
1312
+ await this.withFreshSession(slot, async (session2) => {
1313
+ if (key === "home") {
1314
+ await session2.client.pressKey(session2.getSessionId(), "home");
1315
+ return;
1316
+ }
1317
+ if (key === "enter") {
1318
+ await session2.client.typeText(session2.getSessionId(), { value: ["\n"] });
1319
+ return;
1320
+ }
1321
+ await session2.client.typeText(session2.getSessionId(), { value: [""] });
1322
+ });
978
1323
  }
979
1324
  async waitFor(slot, selector, opts = {}) {
980
1325
  const timeoutMs = opts.timeoutMs ?? 1e4;
@@ -990,13 +1335,64 @@ var WdaDriver = class _WdaDriver {
990
1335
  }
991
1336
  // ---- InspectionDriver --------------------------------------------------
992
1337
  async screenshot(slot) {
993
- const session = await this.getSession(slot);
994
- return session.client.screenshot(session.getSessionId());
1338
+ return this.withFreshSession(
1339
+ slot,
1340
+ (session) => session.client.screenshot(session.getSessionId())
1341
+ );
995
1342
  }
996
1343
  async a11yTree(slot, _opts = {}) {
997
- const session = await this.getSession(slot);
998
- const resp = await session.client.source(session.getSessionId());
999
- return parseWdaSource(resp.value);
1344
+ return this.withFreshSession(slot, async (session) => {
1345
+ const resp = await session.client.source(session.getSessionId());
1346
+ return parseWdaSource(resp.value);
1347
+ });
1348
+ }
1349
+ async windowSize(slot) {
1350
+ return this.withFreshSession(slot, async (session) => {
1351
+ const size = await session.client.windowSize(session.getSessionId());
1352
+ return { width: size.value.width, height: size.value.height };
1353
+ });
1354
+ }
1355
+ // ---- AlertController ---------------------------------------------------
1356
+ //
1357
+ // Native UIAlertController dialogs live in SpringBoard, outside the app's
1358
+ // a11y tree. Resolver-driven `tap()` cannot reach them: even when a
1359
+ // selector "matches" by ordinal, the actions API targets app-process
1360
+ // coordinates and the SpringBoard alert stays put. WDA's /alert/*
1361
+ // endpoints know to talk to the active system alert.
1362
+ async acceptAlert(slot, button) {
1363
+ await this.withFreshSession(slot, async (session) => {
1364
+ await session.client.acceptAlert(
1365
+ session.getSessionId(),
1366
+ button !== void 0 ? { name: button } : {}
1367
+ );
1368
+ });
1369
+ }
1370
+ async dismissAlert(slot) {
1371
+ await this.withFreshSession(
1372
+ slot,
1373
+ (session) => session.client.dismissAlert(session.getSessionId())
1374
+ );
1375
+ }
1376
+ async readAlert(slot) {
1377
+ return this.withFreshSession(slot, async (session) => {
1378
+ const resp = await session.client.alertText(session.getSessionId());
1379
+ return { text: resp.value };
1380
+ });
1381
+ }
1382
+ // `null` on no-alert keeps the A11yTreeTool probe branch-free; the
1383
+ // outline pipeline only renders the `alert:` section when readAlert
1384
+ // succeeded first, so a second NoAlertPresentError here would be a
1385
+ // SpringBoard race anyway — quieter to drop than throw.
1386
+ async readAlertButtons(slot) {
1387
+ return this.withFreshSession(slot, async (session) => {
1388
+ try {
1389
+ const resp = await session.client.alertButtons(session.getSessionId());
1390
+ return resp.value;
1391
+ } catch (e) {
1392
+ if (e instanceof NoAlertPresentError) return null;
1393
+ throw e;
1394
+ }
1395
+ });
1000
1396
  }
1001
1397
  // ---- ContextController stub (D-9) --------------------------------------
1002
1398
  async listContexts(_slot) {
@@ -1011,6 +1407,42 @@ var WdaDriver = class _WdaDriver {
1011
1407
  return _WdaDriver.NATIVE_CONTEXT;
1012
1408
  }
1013
1409
  // ---- internals ---------------------------------------------------------
1410
+ /**
1411
+ * B6 — run `fn` against the cached session for `slot`. If WDA reports
1412
+ * the session is gone (sim erase / app crash / runner killed) we
1413
+ * silently drop the dead session, create a fresh one, and retry the
1414
+ * call ONCE. Persistent failures bubble up to the caller as usual.
1415
+ *
1416
+ * Why ONCE: a single retry covers the "stale handle in cache" case
1417
+ * (the common one — `--erase` between an MCP eval setup and the first
1418
+ * tool call, runner restart, etc). If a second attempt also dies, the
1419
+ * underlying WDA is genuinely broken and a retry loop would just
1420
+ * waste 10+ seconds per call before the harness times out.
1421
+ *
1422
+ * Pre-existing `getSession` flow stays unchanged for ad-hoc internal
1423
+ * calls (resolveBounds / resolveElementId) — those use the cached
1424
+ * session directly and ride the retry of their outer caller.
1425
+ */
1426
+ async withFreshSession(slot, fn) {
1427
+ const session = await this.getSession(slot);
1428
+ try {
1429
+ return await fn(session);
1430
+ } catch (e) {
1431
+ const isSessionGone = e instanceof WdaSessionGoneError || e instanceof Error && e.name === "WdaSessionGoneError";
1432
+ if (!isSessionGone) throw e;
1433
+ const msg = e instanceof Error ? e.message : String(e);
1434
+ this.deps.logger.child(`wda:${slot}`).warn(
1435
+ `WDA session ${session.getSessionId()} is gone (${msg.slice(0, 200)}); dropping cached session and retrying once.`
1436
+ );
1437
+ try {
1438
+ await session.stop();
1439
+ } catch {
1440
+ }
1441
+ this.sessions.delete(slot);
1442
+ const fresh = await this.getSession(slot);
1443
+ return fn(fresh);
1444
+ }
1445
+ }
1014
1446
  async getSession(slot) {
1015
1447
  const existing = this.sessions.get(slot);
1016
1448
  if (existing) return existing;
@@ -1021,6 +1453,23 @@ var WdaDriver = class _WdaDriver {
1021
1453
  `No WDA port configured for slot "${slot}". Set EnvConfig.wdaPortBySlot or add WDA_PORT_${slot} to .env.`
1022
1454
  );
1023
1455
  }
1456
+ if (!this.deps.sessionFactory && !this.deps.appBundleId) {
1457
+ throw new Error(
1458
+ `WDA session needs APP_BUNDLE_ID \u2014 set it in unotest/.env. WebDriverAgent attaches to a specific app via the bundle id.`
1459
+ );
1460
+ }
1461
+ if (!this.deps.sessionFactory && this.deps.appBundleId) {
1462
+ const installed = await this.simctl.isInstalled(handle.udid, this.deps.appBundleId);
1463
+ if (!installed) {
1464
+ throw new Error(
1465
+ `App "${this.deps.appBundleId}" is not installed on ${handle.name} (slot ${slot}).
1466
+ Install it first:
1467
+ npx unotest-mobile install <path-to-.app> # if you have the .app
1468
+ npx unotest-mobile install # if APP_PATH is set in unotest/.env
1469
+ In an MCP session, call the \`app_install\` tool \u2014 it will read APP_PATH or ask you for the path.`
1470
+ );
1471
+ }
1472
+ }
1024
1473
  const session = this.deps.sessionFactory ? this.deps.sessionFactory(slot, handle, port) : new WdaSession({
1025
1474
  slot,
1026
1475
  handle,
@@ -1490,59 +1939,74 @@ function dist(a, b) {
1490
1939
  __name(dist, "dist");
1491
1940
 
1492
1941
  // src/inspection/tree-inspector.ts
1493
- var INTERACTIVE_ROLE_HINTS = [
1942
+ var INTERACTIVE_ROLES = /* @__PURE__ */ new Set([
1494
1943
  "button",
1495
1944
  "textfield",
1496
- "securefield",
1497
- "secureinput",
1498
- "input",
1499
- "link",
1945
+ "securetextfield",
1500
1946
  "switch",
1947
+ "checkbox",
1948
+ "link",
1501
1949
  "slider",
1502
1950
  "picker",
1503
- "checkbox",
1504
1951
  "tab",
1505
1952
  "menuitem"
1506
- ];
1953
+ ]);
1507
1954
  function isInteractiveRole(role) {
1508
- if (!role) return false;
1509
- const r = role.toLowerCase();
1510
- return INTERACTIVE_ROLE_HINTS.some((hint) => r.includes(hint));
1955
+ return role !== void 0 && INTERACTIVE_ROLES.has(role);
1511
1956
  }
1512
1957
  __name(isInteractiveRole, "isInteractiveRole");
1513
1958
  function isNoise(n) {
1514
1959
  return !n.testId && !n.text && !n.label && !isInteractiveRole(n.role);
1515
1960
  }
1516
1961
  __name(isNoise, "isNoise");
1517
- function stripAttrs(n, children) {
1518
- const out = { children };
1962
+ function dedupeFields(n) {
1963
+ const out = { children: n.children };
1964
+ if (n.role) out.role = n.role;
1519
1965
  if (n.testId) out.testId = n.testId;
1520
1966
  if (n.text) out.text = n.text;
1521
1967
  if (n.label) out.label = n.label;
1522
- if (isInteractiveRole(n.role)) out.role = n.role;
1523
- return out;
1524
- }
1525
- __name(stripAttrs, "stripAttrs");
1526
- function compactSubtree(n) {
1527
- let totalSeen = 1;
1528
- const newChildren = [];
1529
- for (const c of n.children) {
1530
- const r = compactSubtree(c);
1531
- totalSeen += r.totalSeen;
1532
- newChildren.push(...r.kept);
1968
+ if (n.clipped) out.clipped = n.clipped;
1969
+ if (out.text && out.label && out.text === out.label) {
1970
+ delete out.label;
1533
1971
  }
1534
- if (isNoise(n)) {
1535
- return { totalSeen, kept: newChildren };
1972
+ const interactive = isInteractiveRole(out.role);
1973
+ if (out.testId && !interactive && (out.testId === out.text || out.testId === out.label)) {
1974
+ delete out.testId;
1536
1975
  }
1537
- return { totalSeen, kept: [stripAttrs(n, newChildren)] };
1976
+ return out;
1538
1977
  }
1539
- __name(compactSubtree, "compactSubtree");
1540
- function countNodes(n) {
1541
- let total = 1;
1542
- for (const c of n.children) total += countNodes(c);
1543
- return total;
1978
+ __name(dedupeFields, "dedupeFields");
1979
+ function classify(bounds, viewport) {
1980
+ if (!bounds) return "on-screen";
1981
+ const { x, y, width, height } = bounds;
1982
+ const yMax = y + height;
1983
+ const xMax = x + width;
1984
+ if (yMax <= 0) return "top";
1985
+ if (y >= viewport.height) return "bottom";
1986
+ if (xMax <= 0) return "left";
1987
+ if (x >= viewport.width) return "right";
1988
+ if (y < 0) return "clipped:top";
1989
+ if (yMax > viewport.height) return "clipped:bottom";
1990
+ if (x < 0) return "clipped:left";
1991
+ if (xMax > viewport.width) return "clipped:right";
1992
+ return "on-screen";
1544
1993
  }
1545
- __name(countNodes, "countNodes");
1994
+ __name(classify, "classify");
1995
+ function clippedSide(c) {
1996
+ switch (c) {
1997
+ case "clipped:top":
1998
+ return "top";
1999
+ case "clipped:bottom":
2000
+ return "bottom";
2001
+ case "clipped:left":
2002
+ return "left";
2003
+ case "clipped:right":
2004
+ return "right";
2005
+ default:
2006
+ return void 0;
2007
+ }
2008
+ }
2009
+ __name(clippedSide, "clippedSide");
1546
2010
  function collectTestIds(n, counts) {
1547
2011
  if (n.testId) counts.set(n.testId, (counts.get(n.testId) ?? 0) + 1);
1548
2012
  for (const c of n.children) collectTestIds(c, counts);
@@ -1557,45 +2021,102 @@ function applyIndex(n, counts, running) {
1557
2021
  for (const c of n.children) applyIndex(c, counts, running);
1558
2022
  }
1559
2023
  __name(applyIndex, "applyIndex");
1560
- function indexDuplicates(tree) {
2024
+ function indexOnScreenDuplicates(nodes) {
1561
2025
  const counts = /* @__PURE__ */ new Map();
1562
- collectTestIds(tree, counts);
1563
- applyIndex(tree, counts, /* @__PURE__ */ new Map());
1564
- return tree;
2026
+ for (const n of nodes) collectTestIds(n, counts);
2027
+ const running = /* @__PURE__ */ new Map();
2028
+ for (const n of nodes) applyIndex(n, counts, running);
2029
+ }
2030
+ __name(indexOnScreenDuplicates, "indexOnScreenDuplicates");
2031
+ function countSemanticNodes(nodes) {
2032
+ let total = 0;
2033
+ for (const n of nodes) {
2034
+ total += 1;
2035
+ total += countSemanticNodes(n.children);
2036
+ }
2037
+ return total;
2038
+ }
2039
+ __name(countSemanticNodes, "countSemanticNodes");
2040
+ function countOffScreen(off) {
2041
+ return off.top.length + off.bottom.length + off.left.length + off.right.length;
2042
+ }
2043
+ __name(countOffScreen, "countOffScreen");
2044
+ function buildSemanticTree(raw, viewport) {
2045
+ const onScreenRoots = [];
2046
+ const offScreen = { top: [], bottom: [], left: [], right: [] };
2047
+ let totalSeen = 0;
2048
+ function makeKept(n, clipped) {
2049
+ const base = { children: [] };
2050
+ if (n.role) base.role = n.role;
2051
+ if (n.testId) base.testId = n.testId;
2052
+ if (n.text) base.text = n.text;
2053
+ if (n.label) base.label = n.label;
2054
+ if (clipped) base.clipped = clipped;
2055
+ return dedupeFields(base);
2056
+ }
2057
+ __name(makeKept, "makeKept");
2058
+ function makeOffScreen(n) {
2059
+ const out = {};
2060
+ if (n.role) out.role = n.role;
2061
+ if (n.testId) out.testId = n.testId;
2062
+ if (n.text) out.text = n.text;
2063
+ if (n.label) out.label = n.label;
2064
+ const deduped = dedupeFields({ ...out, children: [] });
2065
+ const flat = {};
2066
+ if (deduped.role) flat.role = deduped.role;
2067
+ if (deduped.testId) flat.testId = deduped.testId;
2068
+ if (deduped.text) flat.text = deduped.text;
2069
+ if (deduped.label) flat.label = deduped.label;
2070
+ return flat;
2071
+ }
2072
+ __name(makeOffScreen, "makeOffScreen");
2073
+ function walk2(node, onScreenParent) {
2074
+ totalSeen += 1;
2075
+ const c = classify(node.bounds, viewport);
2076
+ const onSide = c === "on-screen" || c.startsWith("clipped:");
2077
+ if (onSide) {
2078
+ if (isNoise(node)) {
2079
+ for (const child of node.children) walk2(child, onScreenParent);
2080
+ return;
2081
+ }
2082
+ const kept = makeKept(node, clippedSide(c));
2083
+ if (onScreenParent) onScreenParent.children.push(kept);
2084
+ else onScreenRoots.push(kept);
2085
+ for (const child of node.children) walk2(child, kept);
2086
+ return;
2087
+ }
2088
+ const side = c;
2089
+ if (!isNoise(node)) {
2090
+ offScreen[side].push(makeOffScreen(node));
2091
+ }
2092
+ for (const child of node.children) walk2(child, null);
2093
+ }
2094
+ __name(walk2, "walk");
2095
+ walk2(raw, null);
2096
+ indexOnScreenDuplicates(onScreenRoots);
2097
+ return {
2098
+ on_screen: onScreenRoots,
2099
+ off_screen: offScreen,
2100
+ _meta: {
2101
+ totalNodes: totalSeen,
2102
+ onScreen: countSemanticNodes(onScreenRoots),
2103
+ offScreen: countOffScreen(offScreen),
2104
+ viewport: { width: viewport.width, height: viewport.height }
2105
+ }
2106
+ };
1565
2107
  }
1566
- __name(indexDuplicates, "indexDuplicates");
2108
+ __name(buildSemanticTree, "buildSemanticTree");
1567
2109
  var TreeInspector = class {
1568
2110
  static {
1569
2111
  __name(this, "TreeInspector");
1570
2112
  }
1571
2113
  /**
1572
- * D-16-aware compaction:
1573
- * - drop wrappers with no identifying signal,
1574
- * - strip layout-only attributes (bounds, enabled, focused, non-interactive role),
1575
- * - keep text-only and label-only leaves,
1576
- * - report `_meta` at the root.
1577
- */
1578
- compactTree(root) {
1579
- const result = compactSubtree(root);
1580
- const out = result.kept.length === 1 ? result.kept[0] : { children: result.kept };
1581
- const shown = countNodes(out);
1582
- out._meta = { totalNodes: result.totalSeen, shown, mode: "compact" };
1583
- return out;
1584
- }
1585
- /**
1586
- * Convenience: compact + index duplicates. The shape most MCP-tools want.
1587
- */
1588
- compactWithIds(root) {
1589
- return indexDuplicates(this.compactTree(root));
1590
- }
1591
- /**
1592
- * Annotate the raw tree as `full` (carries _meta for symmetry, no shape
1593
- * changes). Useful for MCP tools whose only choice is compact vs full.
2114
+ * Partition a raw a11y tree against a viewport. The P1 entry — outline
2115
+ * renderer (in `outline-renderer.ts`) consumes the returned
2116
+ * `SemanticTree` directly.
1594
2117
  */
1595
- withMode(root, mode) {
1596
- if (mode === "compact") return this.compactWithIds(root);
1597
- const total = countNodes(root);
1598
- return { ...root, _meta: { totalNodes: total, shown: total, mode: "full" } };
2118
+ semanticTree(raw, viewport) {
2119
+ return buildSemanticTree(raw, viewport);
1599
2120
  }
1600
2121
  /**
1601
2122
  * DFS path from root to `target`, encoded as "child-index/child-index/...".
@@ -2867,6 +3388,10 @@ var AstExecutor = class {
2867
3388
  }
2868
3389
  return;
2869
3390
  }
3391
+ if (stmt instanceof MetaBlockStatement) {
3392
+ yield* this.execStatement(stmt.body, runtime, opts, getSteps, setSteps, runStartMs, userFns, callStack);
3393
+ return;
3394
+ }
2870
3395
  if (stmt instanceof ReturnStatement) {
2871
3396
  const expr = stmt.expression;
2872
3397
  const value = yield* this.evalExpression(expr, runtime, opts, getSteps, setSteps, runStartMs, userFns, callStack);
@@ -3278,6 +3803,11 @@ var DslLinter = class {
3278
3803
  if (!this.deps.registry.has(name) && !userFns.has(name)) {
3279
3804
  diags.push(diag("error", "E1", `Unknown DSL function "${name}"`, call));
3280
3805
  }
3806
+ const fn = this.deps.registry.get(name);
3807
+ if (fn) {
3808
+ this.checkArity(call, fn, diags);
3809
+ this.checkArgTypes(call, fn, diags);
3810
+ }
3281
3811
  if (name === "setDevice" && call.arguments.length > 0) {
3282
3812
  const arg0 = call.arguments[0];
3283
3813
  if (arg0 instanceof ValueExpression) {
@@ -3313,6 +3843,77 @@ var DslLinter = class {
3313
3843
  this.checkExpression(arg, diags, recordCall, isFirstUiSeen, userFns, allowReturn);
3314
3844
  }
3315
3845
  }
3846
+ checkArity(call, fn, diags) {
3847
+ const max = fn.argTypes.length;
3848
+ const min = fn.minArgs ?? max;
3849
+ const got = call.arguments.length;
3850
+ if (got < min) {
3851
+ diags.push(
3852
+ diag(
3853
+ "error",
3854
+ "E5",
3855
+ `${fn.name}(): expected ${min === max ? `${min}` : `at least ${min}`} arg${min === 1 ? "" : "s"}, got ${got}`,
3856
+ call
3857
+ )
3858
+ );
3859
+ return;
3860
+ }
3861
+ if (!fn.variadic && got > max) {
3862
+ diags.push(
3863
+ diag(
3864
+ "error",
3865
+ "E5",
3866
+ `${fn.name}(): expected at most ${max} arg${max === 1 ? "" : "s"}, got ${got}`,
3867
+ call
3868
+ )
3869
+ );
3870
+ }
3871
+ }
3872
+ /**
3873
+ * For each provided arg up to argTypes.length, infer its static type and
3874
+ * compare to the declared signature. Only emits E6 when the inferred type
3875
+ * is UNAMBIGUOUS — we never flag "any" sources (variables, helper-call
3876
+ * results, BinaryExpression, ArrayExpression).
3877
+ */
3878
+ checkArgTypes(call, fn, diags) {
3879
+ const limit = Math.min(call.arguments.length, fn.argTypes.length);
3880
+ for (let i = 0; i < limit; i++) {
3881
+ const expected = fn.argTypes[i];
3882
+ if (expected === "any") continue;
3883
+ const inferred = this.inferExprType(call.arguments[i]);
3884
+ if (inferred === "any") continue;
3885
+ if (inferred === expected) continue;
3886
+ if (expected === "boolean" && inferred === "number" || expected === "number" && inferred === "boolean") {
3887
+ continue;
3888
+ }
3889
+ diags.push(
3890
+ diag(
3891
+ "error",
3892
+ "E6",
3893
+ `${fn.name}(): arg ${i} expected ${expected}, got ${describeInferred(call.arguments[i], inferred)}`,
3894
+ call
3895
+ )
3896
+ );
3897
+ }
3898
+ }
3899
+ /** Best-effort static type for an expression node. "any" = unknown. */
3900
+ inferExprType(expr) {
3901
+ if (expr instanceof ValueExpression) {
3902
+ const v = expr.value;
3903
+ if (v instanceof StringValue) return "string";
3904
+ if (v instanceof NumberValue) return "number";
3905
+ return "any";
3906
+ }
3907
+ if (expr instanceof VariableExpression) {
3908
+ if (expr.name === "true" || expr.name === "false") return "boolean";
3909
+ return "any";
3910
+ }
3911
+ if (expr instanceof FunctionalExpression) {
3912
+ const callee = this.deps.registry.get(expr.name);
3913
+ return callee ? callee.returnType : "any";
3914
+ }
3915
+ return "any";
3916
+ }
3316
3917
  isForbiddenStatement(stmt) {
3317
3918
  return stmt instanceof ForStatement || stmt instanceof WhileStatement || stmt instanceof DoWhileStatement || stmt instanceof VarStatement || stmt instanceof PrintStatement || stmt instanceof BreakStatement || stmt instanceof ContinueStatement || stmt instanceof IncrementStatement || stmt instanceof ArrayAssignmentStatement;
3318
3919
  }
@@ -3324,6 +3925,19 @@ function isBlockMappable2(name) {
3324
3925
  return name.startsWith("test_") || name.startsWith("flow_");
3325
3926
  }
3326
3927
  __name(isBlockMappable2, "isBlockMappable");
3928
+ function describeInferred(expr, inferred) {
3929
+ if (expr instanceof FunctionalExpression) {
3930
+ return `${inferred} (${expr.name}() returns ${inferred})`;
3931
+ }
3932
+ if (expr instanceof ValueExpression) {
3933
+ return `${inferred} literal`;
3934
+ }
3935
+ if (expr instanceof VariableExpression && (expr.name === "true" || expr.name === "false")) {
3936
+ return `boolean literal \`${expr.name}\``;
3937
+ }
3938
+ return inferred;
3939
+ }
3940
+ __name(describeInferred, "describeInferred");
3327
3941
  function diag(severity, code, message, node) {
3328
3942
  const tok = node.token;
3329
3943
  return {
@@ -3359,6 +3973,41 @@ var FunctionRegistry = class {
3359
3973
  }
3360
3974
  };
3361
3975
 
3976
+ // src/dsl/functions/alerts.ts
3977
+ function asString(x, fn, idx) {
3978
+ if (typeof x !== "string") throw new Error(`${fn}(): arg ${idx} must be a string. Got ${typeof x}.`);
3979
+ return x;
3980
+ }
3981
+ __name(asString, "asString");
3982
+ var acceptAlert = {
3983
+ name: "acceptAlert",
3984
+ argTypes: ["string"],
3985
+ returnType: "void",
3986
+ minArgs: 0,
3987
+ invoke: /* @__PURE__ */ __name(async (runtime, buttonArg) => {
3988
+ const button = buttonArg !== void 0 ? asString(buttonArg, "acceptAlert", 0) : void 0;
3989
+ await runtime.driver.acceptAlert(runtime.currentDeviceSlot, button);
3990
+ }, "invoke")
3991
+ };
3992
+ var dismissAlert = {
3993
+ name: "dismissAlert",
3994
+ argTypes: [],
3995
+ returnType: "void",
3996
+ invoke: /* @__PURE__ */ __name(async (runtime) => {
3997
+ await runtime.driver.dismissAlert(runtime.currentDeviceSlot);
3998
+ }, "invoke")
3999
+ };
4000
+ var readAlert = {
4001
+ name: "readAlert",
4002
+ argTypes: [],
4003
+ returnType: "string",
4004
+ invoke: /* @__PURE__ */ __name(async (runtime) => {
4005
+ const { text } = await runtime.driver.readAlert(runtime.currentDeviceSlot);
4006
+ return text;
4007
+ }, "invoke")
4008
+ };
4009
+ var ALERT_FUNCTIONS = [acceptAlert, dismissAlert, readAlert];
4010
+
3362
4011
  // src/dsl/functions/asserts.ts
3363
4012
  function asSelector(x, fn, idx) {
3364
4013
  if (typeof x !== "object" || x === null) {
@@ -3474,11 +4123,11 @@ var ASSERT_FUNCTIONS = [
3474
4123
  ];
3475
4124
 
3476
4125
  // src/dsl/functions/data.ts
3477
- function asString(x, fn, idx) {
4126
+ function asString2(x, fn, idx) {
3478
4127
  if (typeof x !== "string") throw new Error(`${fn}(): arg ${idx} must be a string. Got ${typeof x}.`);
3479
4128
  return x;
3480
4129
  }
3481
- __name(asString, "asString");
4130
+ __name(asString2, "asString");
3482
4131
  function toSqlParam(v) {
3483
4132
  if (v === null || v === void 0) return null;
3484
4133
  if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") return v;
@@ -3489,8 +4138,9 @@ var dbQuery = {
3489
4138
  name: "dbQuery",
3490
4139
  argTypes: ["string"],
3491
4140
  returnType: "string",
4141
+ variadic: true,
3492
4142
  invoke: /* @__PURE__ */ __name(async (runtime, sqlArg, ...rest) => {
3493
- const sql = asString(sqlArg, "dbQuery", 0);
4143
+ const sql = asString2(sqlArg, "dbQuery", 0);
3494
4144
  const params = rest.map((a, i) => {
3495
4145
  try {
3496
4146
  return toSqlParam(a);
@@ -3511,8 +4161,9 @@ var dbExec = {
3511
4161
  name: "dbExec",
3512
4162
  argTypes: ["string"],
3513
4163
  returnType: "string",
4164
+ variadic: true,
3514
4165
  invoke: /* @__PURE__ */ __name(async (runtime, sqlArg, ...rest) => {
3515
- const sql = asString(sqlArg, "dbExec", 0);
4166
+ const sql = asString2(sqlArg, "dbExec", 0);
3516
4167
  const params = rest.map((a, i) => {
3517
4168
  try {
3518
4169
  return toSqlParam(a);
@@ -3534,8 +4185,9 @@ var shell = {
3534
4185
  name: "shell",
3535
4186
  argTypes: ["string"],
3536
4187
  returnType: "string",
4188
+ variadic: true,
3537
4189
  invoke: /* @__PURE__ */ __name(async (runtime, cmdArg, ...rest) => {
3538
- const cmd = asString(cmdArg, "shell", 0);
4190
+ const cmd = asString2(cmdArg, "shell", 0);
3539
4191
  const args = rest.map((a, i) => {
3540
4192
  if (a === null || a === void 0) return "";
3541
4193
  if (typeof a === "string") return a;
@@ -3553,14 +4205,14 @@ var apiCall = {
3553
4205
  returnType: "string",
3554
4206
  minArgs: 2,
3555
4207
  invoke: /* @__PURE__ */ __name(async (runtime, methodArg, pathArg, bodyArg) => {
3556
- const method = asString(methodArg, "apiCall", 0).toUpperCase();
3557
- const path = asString(pathArg, "apiCall", 1);
4208
+ const method = asString2(methodArg, "apiCall", 0).toUpperCase();
4209
+ const path = asString2(pathArg, "apiCall", 1);
3558
4210
  if (method !== "GET" && method !== "POST" && method !== "PUT" && method !== "PATCH" && method !== "DELETE") {
3559
4211
  throw new Error(`apiCall(): unsupported method "${method}"`);
3560
4212
  }
3561
4213
  let body;
3562
4214
  if (bodyArg !== void 0) {
3563
- const bodyJson = asString(bodyArg, "apiCall", 2);
4215
+ const bodyJson = asString2(bodyArg, "apiCall", 2);
3564
4216
  try {
3565
4217
  body = JSON.parse(bodyJson);
3566
4218
  } catch (e) {
@@ -3580,11 +4232,11 @@ var apiCall = {
3580
4232
  var DATA_FUNCTIONS = [dbQuery, dbExec, apiCall, shell];
3581
4233
 
3582
4234
  // src/dsl/functions/device.ts
3583
- function asString2(x, fn, idx) {
4235
+ function asString3(x, fn, idx) {
3584
4236
  if (typeof x !== "string") throw new Error(`${fn}(): arg ${idx} must be a string. Got ${typeof x}.`);
3585
4237
  return x;
3586
4238
  }
3587
- __name(asString2, "asString");
4239
+ __name(asString3, "asString");
3588
4240
  function asBoolean(x, fn, idx) {
3589
4241
  if (typeof x !== "boolean") throw new Error(`${fn}(): arg ${idx} must be a boolean. Got ${typeof x}.`);
3590
4242
  return x;
@@ -3595,7 +4247,7 @@ var setDevice = {
3595
4247
  argTypes: ["string"],
3596
4248
  returnType: "void",
3597
4249
  invoke: /* @__PURE__ */ __name((runtime, slotArg) => {
3598
- const slot = asString2(slotArg, "setDevice", 0);
4250
+ const slot = asString3(slotArg, "setDevice", 0);
3599
4251
  runtime.setCurrentDeviceSlot(slot);
3600
4252
  }, "invoke")
3601
4253
  };
@@ -3614,7 +4266,7 @@ var openDeeplink = {
3614
4266
  argTypes: ["string"],
3615
4267
  returnType: "void",
3616
4268
  invoke: /* @__PURE__ */ __name(async (runtime, urlArg) => {
3617
- const url = asString2(urlArg, "openDeeplink", 0);
4269
+ const url = asString3(urlArg, "openDeeplink", 0);
3618
4270
  await runtime.driver.openDeeplink(runtime.currentDeviceSlot, url);
3619
4271
  }, "invoke")
3620
4272
  };
@@ -3736,11 +4388,11 @@ function asSelector2(x, fn, idx) {
3736
4388
  return x;
3737
4389
  }
3738
4390
  __name(asSelector2, "asSelector");
3739
- function asString3(x, fn, idx) {
4391
+ function asString4(x, fn, idx) {
3740
4392
  if (typeof x !== "string") throw new Error(`${fn}(): arg ${idx} must be a string. Got ${typeof x}.`);
3741
4393
  return x;
3742
4394
  }
3743
- __name(asString3, "asString");
4395
+ __name(asString4, "asString");
3744
4396
  function asNumber2(x, fn, idx) {
3745
4397
  if (typeof x !== "number") throw new Error(`${fn}(): arg ${idx} must be a number. Got ${typeof x}.`);
3746
4398
  return x;
@@ -3779,7 +4431,7 @@ var type_ = {
3779
4431
  returnType: "void",
3780
4432
  invoke: /* @__PURE__ */ __name(async (runtime, selectorArg, textArg) => {
3781
4433
  const selector = asSelector2(selectorArg, "type", 0);
3782
- const text = asString3(textArg, "type", 1);
4434
+ const text = asString4(textArg, "type", 1);
3783
4435
  await pollUntilFound(runtime, selector, runtime.envConfig.defaultActionWaitMs);
3784
4436
  await runtime.driver.type(runtime.currentDeviceSlot, selector, text);
3785
4437
  }, "invoke")
@@ -3790,7 +4442,7 @@ var swipe = {
3790
4442
  returnType: "void",
3791
4443
  minArgs: 1,
3792
4444
  invoke: /* @__PURE__ */ __name(async (runtime, directionArg, fromArg) => {
3793
- const direction = asString3(directionArg, "swipe", 0);
4445
+ const direction = asString4(directionArg, "swipe", 0);
3794
4446
  if (direction !== "up" && direction !== "down" && direction !== "left" && direction !== "right") {
3795
4447
  throw new Error(`swipe(): direction must be 'up'|'down'|'left'|'right'. Got "${direction}".`);
3796
4448
  }
@@ -3807,7 +4459,7 @@ var pressKey = {
3807
4459
  argTypes: ["string"],
3808
4460
  returnType: "void",
3809
4461
  invoke: /* @__PURE__ */ __name(async (runtime, keyArg) => {
3810
- const key = asString3(keyArg, "pressKey", 0);
4462
+ const key = asString4(keyArg, "pressKey", 0);
3811
4463
  if (key !== "back" && key !== "home" && key !== "enter" && key !== "escape") {
3812
4464
  throw new Error(`pressKey(): key must be 'back'|'home'|'enter'|'escape'. Got "${key}".`);
3813
4465
  }
@@ -3841,6 +4493,7 @@ var ALL_DSL_FUNCTIONS = [
3841
4493
  ...DEVICE_FUNCTIONS,
3842
4494
  ...UI_FUNCTIONS,
3843
4495
  ...SELECTOR_FUNCTIONS,
4496
+ ...ALERT_FUNCTIONS,
3844
4497
  ...DATA_FUNCTIONS,
3845
4498
  ...ASSERT_FUNCTIONS,
3846
4499
  ...TIME_FUNCTIONS
@@ -4021,9 +4674,9 @@ var TestRuntimeManager = class {
4021
4674
  };
4022
4675
 
4023
4676
  // src/runner/fs-scenario-repository.ts
4024
- import { readFileSync as readFileSync2 } from "fs";
4677
+ import { readFileSync as readFileSync2, existsSync as existsSync2, mkdirSync as mkdirSync2, writeFileSync } from "fs";
4025
4678
  import { readdir } from "fs/promises";
4026
- import { resolve, join as join2, sep } from "path";
4679
+ import { dirname as dirname2, resolve, join as join2, sep } from "path";
4027
4680
  var FileSystemScenarioRepository = class {
4028
4681
  static {
4029
4682
  __name(this, "FileSystemScenarioRepository");
@@ -4051,14 +4704,29 @@ var FileSystemScenarioRepository = class {
4051
4704
  }
4052
4705
  }
4053
4706
  async load(name) {
4054
- if (name.includes("..") || name.startsWith("/") || name.includes("\\")) {
4055
- throw new Error(`Invalid scenario name "${name}"`);
4056
- }
4057
- const relPath = name.split("/").join(sep) + ".js";
4058
- const path = join2(this.scenariosDir, relPath);
4707
+ const path = this.pathFor(name);
4059
4708
  const source = readFileSync2(path, "utf8");
4060
4709
  return { name, source };
4061
4710
  }
4711
+ async has(name) {
4712
+ return existsSync2(this.pathFor(name));
4713
+ }
4714
+ async save(name, source, opts) {
4715
+ const path = this.pathFor(name);
4716
+ if (existsSync2(path) && !opts?.overwrite) {
4717
+ throw new Error(`scenario "${name}" already exists at ${path}`);
4718
+ }
4719
+ mkdirSync2(dirname2(path), { recursive: true });
4720
+ writeFileSync(path, source);
4721
+ return path;
4722
+ }
4723
+ pathFor(name) {
4724
+ if (name.includes("..") || name.startsWith("/") || name.includes("\\")) {
4725
+ throw new Error(`Invalid scenario name "${name}"`);
4726
+ }
4727
+ const relPath = name.split("/").join(sep) + ".js";
4728
+ return join2(this.scenariosDir, relPath);
4729
+ }
4062
4730
  };
4063
4731
 
4064
4732
  // vendor/dsl/parser/token.ts
@@ -4886,7 +5554,7 @@ __name(collectOrigins, "collectOrigins");
4886
5554
 
4887
5555
  // src/runner/helper-repository.ts
4888
5556
  import { readdir as readdir2, readFile } from "fs/promises";
4889
- import { existsSync as existsSync2 } from "fs";
5557
+ import { existsSync as existsSync3 } from "fs";
4890
5558
  import { join as join3, relative } from "path";
4891
5559
  var FileSystemHelperRepository = class {
4892
5560
  static {
@@ -4899,7 +5567,7 @@ var FileSystemHelperRepository = class {
4899
5567
  this.helpersDir = join3(deps.scenariosDir, "_helpers");
4900
5568
  }
4901
5569
  async loadAll() {
4902
- if (!existsSync2(this.helpersDir)) return [];
5570
+ if (!existsSync3(this.helpersDir)) return [];
4903
5571
  const files = await collectJsFiles(this.helpersDir);
4904
5572
  const out = [];
4905
5573
  for (const absolutePath of files) {
@@ -4932,28 +5600,373 @@ async function collectJsFiles(dir) {
4932
5600
  __name(collectJsFiles, "collectJsFiles");
4933
5601
 
4934
5602
  // src/mcp/session-recorder.ts
4935
- import { appendFileSync, mkdirSync as mkdirSync2, writeFileSync } from "fs";
4936
- import { dirname as dirname2, resolve as resolve2 } from "path";
5603
+ import { appendFileSync, mkdirSync as mkdirSync3, writeFileSync as writeFileSync2 } from "fs";
5604
+ import { dirname as dirname3, resolve as resolve2 } from "path";
4937
5605
  var SessionRecorder = class {
4938
5606
  static {
4939
5607
  __name(this, "SessionRecorder");
4940
5608
  }
4941
5609
  path;
4942
- constructor(path) {
5610
+ full;
5611
+ constructor(path, opts = {}) {
4943
5612
  this.path = resolve2(process.cwd(), path);
4944
- mkdirSync2(dirname2(this.path), { recursive: true });
5613
+ this.full = opts.full ?? false;
5614
+ mkdirSync3(dirname3(this.path), { recursive: true });
4945
5615
  }
4946
5616
  record(action) {
4947
- const entry = { ts: (/* @__PURE__ */ new Date()).toISOString(), ...action };
5617
+ const { full_result, ...rest } = action;
5618
+ const entry = { ts: (/* @__PURE__ */ new Date()).toISOString(), ...rest };
5619
+ if (this.full && full_result !== void 0) entry.full_result = full_result;
4948
5620
  appendFileSync(this.path, JSON.stringify(entry) + "\n");
4949
5621
  }
4950
5622
  reset() {
4951
- writeFileSync(this.path, "");
5623
+ writeFileSync2(this.path, "");
4952
5624
  }
4953
5625
  getPath() {
4954
5626
  return this.path;
4955
5627
  }
4956
5628
  };
5629
+ var NoopSessionRecorder = class {
5630
+ static {
5631
+ __name(this, "NoopSessionRecorder");
5632
+ }
5633
+ record(_action) {
5634
+ }
5635
+ reset() {
5636
+ }
5637
+ getPath() {
5638
+ return "(disabled)";
5639
+ }
5640
+ };
5641
+
5642
+ // src/mcp/exploration/exploration.service.ts
5643
+ import { mkdirSync as mkdirSync4, readdirSync as readdirSync2, readFileSync as readFileSync3, appendFileSync as appendFileSync2, statSync } from "fs";
5644
+ import { join as join4 } from "path";
5645
+ import { randomUUID } from "crypto";
5646
+
5647
+ // src/mcp/exploration/stability.ts
5648
+ function classifyStability(selector) {
5649
+ if (!selector) return "stable";
5650
+ if (selector.testId) return "stable";
5651
+ if (selector.label && selector.ordinal === void 0) return "stable";
5652
+ if (selector.text && selector.ordinal === void 0) return "stable";
5653
+ return "fragile";
5654
+ }
5655
+ __name(classifyStability, "classifyStability");
5656
+
5657
+ // src/mcp/exploration/exploration.service.ts
5658
+ var ExplorationService = class {
5659
+ static {
5660
+ __name(this, "ExplorationService");
5661
+ }
5662
+ sessions = /* @__PURE__ */ new Map();
5663
+ logs = /* @__PURE__ */ new Map();
5664
+ dir;
5665
+ clock;
5666
+ generateId;
5667
+ constructor(deps) {
5668
+ this.dir = deps.explorationsDir;
5669
+ this.clock = deps.clock ?? (() => /* @__PURE__ */ new Date());
5670
+ this.generateId = deps.generateId ?? (() => randomUUID());
5671
+ mkdirSync4(this.dir, { recursive: true });
5672
+ this.restore();
5673
+ }
5674
+ startSession(params) {
5675
+ const explorationId = `exp-${this.generateId()}`;
5676
+ const actionLogId = `log-${this.generateId()}`;
5677
+ const startedAt = this.clock().toISOString();
5678
+ const session = {
5679
+ explorationId,
5680
+ scenarioName: params.scenarioName,
5681
+ device: params.device,
5682
+ status: "active",
5683
+ startedAt,
5684
+ actionLogId,
5685
+ ...params.title !== void 0 ? { title: params.title } : {},
5686
+ ...params.description !== void 0 ? { description: params.description } : {}
5687
+ };
5688
+ this.sessions.set(explorationId, session);
5689
+ this.logs.set(actionLogId, { logId: actionLogId, createdAt: startedAt, entries: [] });
5690
+ this.append(explorationId, { kind: "session_started", session });
5691
+ return session;
5692
+ }
5693
+ getSession(explorationId) {
5694
+ return this.sessions.get(explorationId);
5695
+ }
5696
+ getLog(explorationId) {
5697
+ const s = this.sessions.get(explorationId);
5698
+ return s ? this.logs.get(s.actionLogId) : void 0;
5699
+ }
5700
+ addEntry(explorationId, params) {
5701
+ const session = this.requireActive(explorationId);
5702
+ const log = this.logs.get(session.actionLogId);
5703
+ const entry = {
5704
+ entryId: `e-${this.generateId()}`,
5705
+ performedAt: this.clock().toISOString(),
5706
+ action: params.action,
5707
+ device: params.device,
5708
+ description: params.description,
5709
+ section: params.section,
5710
+ stability: classifyStability(params.selector),
5711
+ ...params.selector !== void 0 ? { selector: params.selector } : {},
5712
+ ...params.value !== void 0 ? { value: params.value } : {},
5713
+ ...params.key !== void 0 ? { key: params.key } : {},
5714
+ ...params.direction !== void 0 ? { direction: params.direction } : {},
5715
+ ...params.from !== void 0 ? { from: params.from } : {},
5716
+ ...params.url !== void 0 ? { url: params.url } : {},
5717
+ ...params.bundleId !== void 0 ? { bundleId: params.bundleId } : {},
5718
+ ...params.clean !== void 0 ? { clean: params.clean } : {},
5719
+ ...params.timeoutMs !== void 0 ? { timeoutMs: params.timeoutMs } : {},
5720
+ ...params.optional !== void 0 ? { optional: params.optional } : {},
5721
+ ...params.button !== void 0 ? { button: params.button } : {}
5722
+ };
5723
+ log.entries.push(entry);
5724
+ this.append(explorationId, { kind: "entry_added", entry });
5725
+ return entry;
5726
+ }
5727
+ removeEntry(explorationId, entryId) {
5728
+ const session = this.requireKnown(explorationId);
5729
+ const log = this.logs.get(session.actionLogId);
5730
+ const idx = log.entries.findIndex((e) => e.entryId === entryId);
5731
+ if (idx < 0) return false;
5732
+ log.entries.splice(idx, 1);
5733
+ this.append(explorationId, { kind: "entry_removed", entryId });
5734
+ return true;
5735
+ }
5736
+ stopSession(explorationId) {
5737
+ const session = this.requireKnown(explorationId);
5738
+ if (session.status === "stopped") return session;
5739
+ session.status = "stopped";
5740
+ this.append(explorationId, { kind: "session_stopped", stoppedAt: this.clock().toISOString() });
5741
+ return session;
5742
+ }
5743
+ listSessions() {
5744
+ return [...this.sessions.values()];
5745
+ }
5746
+ // ──────────────────────────────────────────────────────────────────────
5747
+ requireKnown(explorationId) {
5748
+ const s = this.sessions.get(explorationId);
5749
+ if (!s) throw new ExplorationNotFoundError(explorationId);
5750
+ return s;
5751
+ }
5752
+ requireActive(explorationId) {
5753
+ const s = this.requireKnown(explorationId);
5754
+ if (s.status !== "active") {
5755
+ throw new ExplorationStoppedError(explorationId);
5756
+ }
5757
+ return s;
5758
+ }
5759
+ append(explorationId, record) {
5760
+ appendFileSync2(join4(this.dir, `${explorationId}.jsonl`), JSON.stringify(record) + "\n");
5761
+ }
5762
+ restore() {
5763
+ let files;
5764
+ try {
5765
+ files = readdirSync2(this.dir).filter((f) => f.endsWith(".jsonl"));
5766
+ } catch {
5767
+ return;
5768
+ }
5769
+ for (const file of files) {
5770
+ const full = join4(this.dir, file);
5771
+ try {
5772
+ if (!statSync(full).isFile()) continue;
5773
+ const text = readFileSync3(full, "utf8");
5774
+ this.replayFile(text);
5775
+ } catch {
5776
+ }
5777
+ }
5778
+ }
5779
+ replayFile(jsonl) {
5780
+ const lines = jsonl.split("\n").filter((l) => l.length > 0);
5781
+ let session = null;
5782
+ let log = null;
5783
+ for (const line of lines) {
5784
+ let rec;
5785
+ try {
5786
+ rec = JSON.parse(line);
5787
+ } catch {
5788
+ continue;
5789
+ }
5790
+ switch (rec.kind) {
5791
+ case "session_started": {
5792
+ session = { ...rec.session };
5793
+ log = { logId: session.actionLogId, createdAt: session.startedAt, entries: [] };
5794
+ this.sessions.set(session.explorationId, session);
5795
+ this.logs.set(session.actionLogId, log);
5796
+ break;
5797
+ }
5798
+ case "entry_added": {
5799
+ if (log) log.entries.push(rec.entry);
5800
+ break;
5801
+ }
5802
+ case "entry_removed": {
5803
+ if (log) {
5804
+ const idx = log.entries.findIndex((e) => e.entryId === rec.entryId);
5805
+ if (idx >= 0) log.entries.splice(idx, 1);
5806
+ }
5807
+ break;
5808
+ }
5809
+ case "session_stopped": {
5810
+ if (session) session.status = "stopped";
5811
+ break;
5812
+ }
5813
+ }
5814
+ }
5815
+ }
5816
+ };
5817
+ var ExplorationNotFoundError = class extends Error {
5818
+ static {
5819
+ __name(this, "ExplorationNotFoundError");
5820
+ }
5821
+ code = "EXPLORATION_NOT_FOUND";
5822
+ constructor(explorationId) {
5823
+ super(`exploration session not found: ${explorationId}`);
5824
+ this.name = "ExplorationNotFoundError";
5825
+ }
5826
+ };
5827
+ var ExplorationStoppedError = class extends Error {
5828
+ static {
5829
+ __name(this, "ExplorationStoppedError");
5830
+ }
5831
+ code = "EXPLORATION_STOPPED";
5832
+ constructor(explorationId) {
5833
+ super(`exploration session already stopped: ${explorationId}`);
5834
+ this.name = "ExplorationStoppedError";
5835
+ }
5836
+ };
5837
+
5838
+ // src/mcp/exploration/format-selector.ts
5839
+ function formatSelector(selector) {
5840
+ if (selector.ordinal !== void 0) {
5841
+ const { ordinal: ordinal2, ...rest } = selector;
5842
+ const inner = formatSelector(rest);
5843
+ return inner === null ? null : `ordinal(${inner}, ${ordinal2})`;
5844
+ }
5845
+ if (selector.testId !== void 0) return `getByTestId(${JSON.stringify(selector.testId)})`;
5846
+ if (selector.text !== void 0) return `getByText(${JSON.stringify(selector.text)})`;
5847
+ if (selector.label !== void 0) return `getByLabel(${JSON.stringify(selector.label)})`;
5848
+ return null;
5849
+ }
5850
+ __name(formatSelector, "formatSelector");
5851
+ function describeShape(selector) {
5852
+ const keys = Object.keys(selector).filter((k) => selector[k] !== void 0);
5853
+ return keys.length === 0 ? "<empty>" : keys.sort().join("+");
5854
+ }
5855
+ __name(describeShape, "describeShape");
5856
+
5857
+ // src/mcp/exploration/dsl-view.service.ts
5858
+ var DslViewService = class {
5859
+ static {
5860
+ __name(this, "DslViewService");
5861
+ }
5862
+ generate(session, log) {
5863
+ const warnings = [];
5864
+ const lines = [];
5865
+ lines.push(renderHeader(session));
5866
+ lines.push(`function test_${session.scenarioName.replace(/[^a-zA-Z0-9_]/g, "_")}() {`);
5867
+ lines.push(` setDevice(${JSON.stringify(session.device)});`);
5868
+ let openSection = null;
5869
+ for (const entry of log.entries) {
5870
+ if (entry.section !== openSection) {
5871
+ if (openSection !== null) lines.push(" //@endcollapse");
5872
+ lines.push(` //@collapse(${JSON.stringify(entry.section)})`);
5873
+ openSection = entry.section;
5874
+ }
5875
+ const result = renderEntry(entry);
5876
+ if (result.kind === "skip") {
5877
+ warnings.push({
5878
+ entryId: entry.entryId,
5879
+ type: "NO_DSL_PRIMITIVE",
5880
+ message: result.reason
5881
+ });
5882
+ lines.push(` // SKIPPED (${result.reason}) \u2014 replace with getByTestId/getByText/getByLabel`);
5883
+ continue;
5884
+ }
5885
+ if (entry.stability === "fragile") {
5886
+ warnings.push({
5887
+ entryId: entry.entryId,
5888
+ type: "FRAGILE_LOCATOR",
5889
+ message: `${entry.action} selector lacks a stable identifier \u2014 add a testId in the app`
5890
+ });
5891
+ }
5892
+ for (const w of result.warnings ?? []) {
5893
+ warnings.push({ ...w, entryId: entry.entryId });
5894
+ }
5895
+ lines.push(" " + result.stmt);
5896
+ }
5897
+ if (openSection !== null) lines.push(" //@endcollapse");
5898
+ lines.push("}");
5899
+ return { draftDsl: lines.join("\n") + "\n", warnings };
5900
+ }
5901
+ };
5902
+ function renderHeader(session) {
5903
+ const title = session.title ?? session.scenarioName;
5904
+ return [
5905
+ `// id-${session.scenarioName}`,
5906
+ `// ${title}`,
5907
+ `// #4287f5`
5908
+ ].join("\n");
5909
+ }
5910
+ __name(renderHeader, "renderHeader");
5911
+ function renderEntry(entry) {
5912
+ const sel = /* @__PURE__ */ __name((s) => {
5913
+ const out = formatSelector(s);
5914
+ if (out === null) {
5915
+ return { kind: "skip", reason: `selector shape ${describeShape(s)} has no DSL primitive` };
5916
+ }
5917
+ return out;
5918
+ }, "sel");
5919
+ switch (entry.action) {
5920
+ case "tap": {
5921
+ if (!entry.selector) return { kind: "skip", reason: "tap missing selector" };
5922
+ const s = sel(entry.selector);
5923
+ if (typeof s !== "string") return s;
5924
+ return { kind: "stmt", stmt: `tap(${s});` };
5925
+ }
5926
+ case "type": {
5927
+ if (!entry.selector) return { kind: "skip", reason: "type missing selector" };
5928
+ const s = sel(entry.selector);
5929
+ if (typeof s !== "string") return s;
5930
+ return { kind: "stmt", stmt: `type(${s}, ${JSON.stringify(entry.value ?? "")});` };
5931
+ }
5932
+ case "swipe": {
5933
+ const dir = JSON.stringify(entry.direction ?? "up");
5934
+ if (entry.from) {
5935
+ const s = sel(entry.from);
5936
+ if (typeof s !== "string") return s;
5937
+ return { kind: "stmt", stmt: `swipe(${dir}, ${s});` };
5938
+ }
5939
+ return { kind: "stmt", stmt: `swipe(${dir});` };
5940
+ }
5941
+ case "wait_for": {
5942
+ if (!entry.selector) return { kind: "skip", reason: "wait_for missing selector" };
5943
+ const s = sel(entry.selector);
5944
+ if (typeof s !== "string") return s;
5945
+ return { kind: "stmt", stmt: `waitFor(${s}, ${entry.timeoutMs ?? 5e3});` };
5946
+ }
5947
+ case "press_key":
5948
+ return { kind: "stmt", stmt: `pressKey(${JSON.stringify(entry.key ?? "enter")});` };
5949
+ case "app_launch": {
5950
+ const warnings = entry.bundleId ? [
5951
+ {
5952
+ type: "BUNDLE_ID_IGNORED",
5953
+ message: `app_launch recorded with bundleId="${entry.bundleId}" \u2014 DSL appLaunch() reads bundle from APP_BUNDLE_ID env; override at runtime if needed.`
5954
+ }
5955
+ ] : [];
5956
+ return { kind: "stmt", stmt: `appLaunch(${entry.clean ? "true" : "false"});`, warnings };
5957
+ }
5958
+ case "open_deeplink":
5959
+ return { kind: "stmt", stmt: `openDeeplink(${JSON.stringify(entry.url ?? "")});` };
5960
+ case "accept_alert":
5961
+ return {
5962
+ kind: "stmt",
5963
+ stmt: entry.button ? `acceptAlert(${JSON.stringify(entry.button)});` : `acceptAlert();`
5964
+ };
5965
+ case "dismiss_alert":
5966
+ return { kind: "stmt", stmt: `dismissAlert();` };
5967
+ }
5968
+ }
5969
+ __name(renderEntry, "renderEntry");
4957
5970
 
4958
5971
  // src/fixtures/db.ts
4959
5972
  async function createDbClient(databaseUrl) {
@@ -5159,9 +6172,13 @@ async function buildApp(opts = {}) {
5159
6172
  const scenarioRepository = opts.scenarioRepository ?? new FileSystemScenarioRepository({ scenariosDir: scenariosDirAbs });
5160
6173
  const helperRepository = opts.helperRepository ?? new FileSystemHelperRepository({ scenariosDir: scenariosDirAbs });
5161
6174
  const scenarioLoader = new ScenarioLoader(scenarioRepository, helperRepository);
5162
- const sessionRecorder = new SessionRecorder(envConfig.SESSION_LOG_PATH);
5163
- const db = opts.db ?? await createDbClient(envConfig.DATABASE_URL);
5164
- const api = createApiClient(envConfig.API_BASE_URL, logger.child("api"));
6175
+ const sessionRecorder = envConfig.SESSION_LOG_DISABLE || envConfig.SESSION_LOG_PATH === "" ? new NoopSessionRecorder() : new SessionRecorder(envConfig.SESSION_LOG_PATH, { full: envConfig.SESSION_LOG_FULL });
6176
+ const exploration = new ExplorationService({
6177
+ explorationsDir: resolve3(envConfig.explorationsDir)
6178
+ });
6179
+ const dslView = new DslViewService();
6180
+ const db = opts.db ?? (envConfig.DATABASE_URL ? await createDbClient(envConfig.DATABASE_URL) : missingEnvDbClient());
6181
+ const api = envConfig.API_BASE_URL ? createApiClient(envConfig.API_BASE_URL, logger.child("api")) : missingEnvApiClient();
5165
6182
  const shell2 = opts.shell ?? new NodeShellExecutor({
5166
6183
  logger: logger.child("shell"),
5167
6184
  defaultCwd: envConfig.PROJECT_ROOT ?? process.cwd()
@@ -5185,6 +6202,8 @@ async function buildApp(opts = {}) {
5185
6202
  helperRepository,
5186
6203
  scenarioLoader,
5187
6204
  sessionRecorder,
6205
+ exploration,
6206
+ dslView,
5188
6207
  db,
5189
6208
  api,
5190
6209
  shell: shell2,
@@ -5200,9 +6219,53 @@ async function buildApp(opts = {}) {
5200
6219
  };
5201
6220
  }
5202
6221
  __name(buildApp, "buildApp");
6222
+ function missingEnvDbClient() {
6223
+ const fail = /* @__PURE__ */ __name(() => {
6224
+ throw new Error(
6225
+ `DATABASE_URL is not set in unotest/.env \u2014 required for dbQuery / dbExec. Set it (postgresql://, mysql://, or sqlite:) or remove the dbQuery / dbExec call from the scenario.`
6226
+ );
6227
+ }, "fail");
6228
+ return {
6229
+ query: /* @__PURE__ */ __name(() => fail(), "query"),
6230
+ exec: /* @__PURE__ */ __name(() => fail(), "exec"),
6231
+ close: /* @__PURE__ */ __name(async () => {
6232
+ }, "close")
6233
+ };
6234
+ }
6235
+ __name(missingEnvDbClient, "missingEnvDbClient");
6236
+ function missingEnvApiClient() {
6237
+ const fail = /* @__PURE__ */ __name(() => {
6238
+ throw new Error(
6239
+ `API_BASE_URL is not set in unotest/.env \u2014 required for apiCall. Set it or remove the apiCall call from the scenario.`
6240
+ );
6241
+ }, "fail");
6242
+ return {
6243
+ call: /* @__PURE__ */ __name(() => fail(), "call"),
6244
+ login: /* @__PURE__ */ __name(() => fail(), "login")
6245
+ };
6246
+ }
6247
+ __name(missingEnvApiClient, "missingEnvApiClient");
5203
6248
 
5204
- // src/mcp/tools/legacy.tool.ts
5205
- import { z as z3 } from "zod";
6249
+ // src/util/cli-entry.ts
6250
+ function runMain(main, errorExitCode) {
6251
+ main().then(
6252
+ (code) => process.exit(code),
6253
+ (e) => {
6254
+ const msg = e instanceof Error ? e.message : String(e);
6255
+ process.stderr.write(`\u2717 ${msg}
6256
+ `);
6257
+ if (process.env.UNOTEST_DEBUG === "1" && e instanceof Error && e.stack) {
6258
+ process.stderr.write(`${e.stack}
6259
+ `);
6260
+ }
6261
+ process.exit(errorExitCode);
6262
+ }
6263
+ );
6264
+ }
6265
+ __name(runMain, "runMain");
6266
+
6267
+ // src/mcp/tools/device.tool.ts
6268
+ import { z as z2 } from "zod";
5206
6269
 
5207
6270
  // src/mcp/tools/base.tool.ts
5208
6271
  var BaseTool = class {
@@ -5237,58 +6300,180 @@ var BaseTool = class {
5237
6300
  }
5238
6301
  }
5239
6302
  /**
5240
- * Wrap `fn` with session-log recording AND error→fail conversion. The
5241
- * recorder lives on AppServices (Phase 8 addition); slices that include it
5242
- * get tracking for free, slices that don't simply skip recording.
6303
+ * Wrap `fn` with session-log recording AND error→fail conversion.
6304
+ *
6305
+ * Three outcomes are classified:
6306
+ * - thrown exception → result: "error", error_class = exception class name
6307
+ * - returned `{isError: true}` (e.g. via `this.fail(...)`) → result: "error",
6308
+ * error_class = "ReturnedError"
6309
+ * - any other return → result: "ok"
5243
6310
  *
5244
- * Use this from concrete tool `register` callbacks the convention saves
5245
- * 4-5 lines of boilerplate per tool.
6311
+ * Duration is measured in both branches. A short result_preview (truncated
6312
+ * concatenation of text content) is always recorded; the full result is
6313
+ * passed to the recorder, which writes it only when SESSION_LOG_FULL=1.
5246
6314
  */
5247
6315
  async tracked(toolName, args, fn) {
5248
6316
  const recorder = this.services.sessionRecorder;
6317
+ const t0 = Date.now();
5249
6318
  try {
5250
6319
  const out = await fn();
5251
- if (recorder) recorder.record({ tool: toolName, args, result: "ok" });
6320
+ const duration_ms = Date.now() - t0;
6321
+ if (recorder) {
6322
+ if (isReturnedError(out)) {
6323
+ const errText = extractText(out);
6324
+ recorder.record({
6325
+ tool: toolName,
6326
+ args,
6327
+ result: "error",
6328
+ error: errText,
6329
+ error_class: "ReturnedError",
6330
+ duration_ms,
6331
+ result_preview: truncate2(errText),
6332
+ full_result: out
6333
+ });
6334
+ } else {
6335
+ const preview = truncate2(extractText(out));
6336
+ recorder.record({
6337
+ tool: toolName,
6338
+ args,
6339
+ result: "ok",
6340
+ duration_ms,
6341
+ result_preview: preview,
6342
+ full_result: out
6343
+ });
6344
+ }
6345
+ }
5252
6346
  return out;
5253
6347
  } catch (e) {
6348
+ const duration_ms = Date.now() - t0;
5254
6349
  const msg = e instanceof Error ? e.message : String(e);
5255
- if (recorder) recorder.record({ tool: toolName, args, result: "error", error: msg });
6350
+ const error_class = e instanceof Error ? e.constructor.name : "UnknownError";
6351
+ if (recorder) {
6352
+ recorder.record({
6353
+ tool: toolName,
6354
+ args,
6355
+ result: "error",
6356
+ error: msg,
6357
+ error_class,
6358
+ duration_ms,
6359
+ result_preview: truncate2(msg)
6360
+ });
6361
+ }
5256
6362
  return this.fail(msg);
5257
6363
  }
5258
6364
  }
5259
6365
  };
6366
+ var PREVIEW_MAX_BYTES = 4096;
6367
+ function truncate2(s) {
6368
+ if (s.length <= PREVIEW_MAX_BYTES) return s;
6369
+ return s.slice(0, PREVIEW_MAX_BYTES) + `\u2026[truncated ${s.length - PREVIEW_MAX_BYTES} chars]`;
6370
+ }
6371
+ __name(truncate2, "truncate");
6372
+ function isReturnedError(out) {
6373
+ return typeof out === "object" && out !== null && out.isError === true;
6374
+ }
6375
+ __name(isReturnedError, "isReturnedError");
6376
+ function extractText(out) {
6377
+ if (typeof out !== "object" || out === null) return String(out);
6378
+ const content = out.content;
6379
+ if (!Array.isArray(content)) return "";
6380
+ const parts = [];
6381
+ for (const item of content) {
6382
+ if (item && typeof item === "object" && item.type === "text") {
6383
+ const text = item.text;
6384
+ if (typeof text === "string") parts.push(text);
6385
+ }
6386
+ }
6387
+ return parts.join("\n");
6388
+ }
6389
+ __name(extractText, "extractText");
5260
6390
 
5261
- // src/mcp/tools/selector-param.ts
5262
- import { z as z2 } from "zod";
5263
- var SelectorShape = {
5264
- testId: z2.string().optional(),
5265
- text: z2.string().optional(),
5266
- label: z2.string().optional(),
5267
- ordinal: z2.number().int().nonnegative().optional(),
5268
- pointPercent: z2.object({
5269
- x: z2.number().min(0).max(1),
5270
- y: z2.number().min(0).max(1)
5271
- }).optional()
5272
- };
5273
- function coerceJsonObject(v) {
5274
- if (typeof v !== "string") return v;
5275
- try {
5276
- return JSON.parse(v);
5277
- } catch {
5278
- return v;
6391
+ // src/inspection/outline-types.ts
6392
+ var OUTLINE_TESTID_RE = /^[^\s"{}\x00-\x1F\x7F]+$/;
6393
+
6394
+ // src/inspection/outline-renderer.ts
6395
+ var SIDES = ["top", "bottom", "left", "right"];
6396
+ function renderOutline(tree) {
6397
+ const lines = [];
6398
+ if (tree.alert) {
6399
+ renderAlertSection(tree.alert, lines);
6400
+ }
6401
+ lines.push("on_screen:");
6402
+ if (tree.on_screen.length === 0) {
6403
+ lines.push(" (empty)");
6404
+ } else {
6405
+ for (const root of tree.on_screen) renderNode(root, 1, lines);
6406
+ }
6407
+ lines.push("off_screen:");
6408
+ for (const side of SIDES) {
6409
+ const list = tree.off_screen[side];
6410
+ if (list.length === 0) {
6411
+ lines.push(` ${side}: (empty)`);
6412
+ } else {
6413
+ lines.push(` ${side}:`);
6414
+ for (const node of list) {
6415
+ lines.push(` ${renderLine(
6416
+ node,
6417
+ /*isOffScreen*/
6418
+ true
6419
+ )}`);
6420
+ }
6421
+ }
5279
6422
  }
6423
+ const m = tree._meta;
6424
+ lines.push("_meta:");
6425
+ lines.push(
6426
+ ` totalNodes: ${m.totalNodes} onScreen: ${m.onScreen} offScreen: ${m.offScreen}`
6427
+ );
6428
+ lines.push(` viewport: ${m.viewport.width}x${m.viewport.height}`);
6429
+ lines.push(` mode: outline`);
6430
+ if (tree.alert) lines.push(` alert_active: true`);
6431
+ return lines.join("\n");
5280
6432
  }
5281
- __name(coerceJsonObject, "coerceJsonObject");
5282
- var SelectorParam = z2.preprocess(
5283
- coerceJsonObject,
5284
- z2.object(SelectorShape).refine(
5285
- (s) => Boolean(s.testId || s.text || s.label || s.pointPercent),
5286
- { message: "selector requires one of testId/text/label/pointPercent" }
5287
- )
5288
- );
5289
- var SelectorParamOptional = z2.preprocess(coerceJsonObject, z2.object(SelectorShape).optional());
6433
+ __name(renderOutline, "renderOutline");
6434
+ function renderAlertSection(alert, out) {
6435
+ out.push("alert:");
6436
+ out.push(` text: "${escapeName(alert.text)}"`);
6437
+ if (alert.buttons !== null) {
6438
+ out.push(` buttons: [${alert.buttons.join(", ")}]`);
6439
+ }
6440
+ out.push(` hint: use acceptAlert("<label>") or dismissAlert \u2014 direct tap won't reach SpringBoard`);
6441
+ }
6442
+ __name(renderAlertSection, "renderAlertSection");
6443
+ function renderNode(n, depth, out) {
6444
+ out.push(" ".repeat(depth) + renderLine(
6445
+ n,
6446
+ /*isOffScreen*/
6447
+ false
6448
+ ));
6449
+ for (const c of n.children) renderNode(c, depth + 1, out);
6450
+ }
6451
+ __name(renderNode, "renderNode");
6452
+ function renderLine(n, isOffScreen) {
6453
+ const parts = ["-"];
6454
+ const interactive = isInteractiveRole(n.role);
6455
+ const name = interactive && n.text !== void 0 && n.text === n.testId && n.label && n.label !== n.testId ? n.label : n.text ?? n.label;
6456
+ const isRedundantRole = n.role === "statictext" || n.role === "other";
6457
+ const showRole = !!n.role && !(name !== void 0 && isRedundantRole);
6458
+ if (showRole) parts.push(n.role);
6459
+ if (name !== void 0) parts.push(`"${escapeName(name)}"`);
6460
+ const testIdShouldShow = n.testId !== void 0 && OUTLINE_TESTID_RE.test(n.testId) && (interactive || n.testId !== name);
6461
+ if (testIdShouldShow) parts.push(`#${n.testId}`);
6462
+ const flags = [];
6463
+ if (!isOffScreen) {
6464
+ const clipped = n.clipped;
6465
+ if (clipped) flags.push(`clipped: ${clipped}`);
6466
+ }
6467
+ if (flags.length > 0) parts.push(`{${flags.join(", ")}}`);
6468
+ return parts.join(" ");
6469
+ }
6470
+ __name(renderLine, "renderLine");
6471
+ function escapeName(s) {
6472
+ return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t");
6473
+ }
6474
+ __name(escapeName, "escapeName");
5290
6475
 
5291
- // src/mcp/tools/legacy.tool.ts
6476
+ // src/mcp/tools/device.tool.ts
5292
6477
  var DevicesListTool = class extends BaseTool {
5293
6478
  static {
5294
6479
  __name(this, "DevicesListTool");
@@ -5332,7 +6517,7 @@ var ScreenshotTool = class extends BaseTool {
5332
6517
  {
5333
6518
  title: "Screenshot",
5334
6519
  description: "Returns a PNG screenshot of the device as MCP image content (inline base64). Does not write to disk.",
5335
- inputSchema: { device: z3.string().describe("device slot, e.g. 'A'") }
6520
+ inputSchema: { device: z2.string().describe("device slot, e.g. 'A'") }
5336
6521
  },
5337
6522
  async ({ device }) => this.tracked("screenshot", { device }, async () => {
5338
6523
  const buf = await this.services.driver.screenshot(device);
@@ -5354,178 +6539,46 @@ var A11yTreeTool = class extends BaseTool {
5354
6539
  "a11y_tree",
5355
6540
  {
5356
6541
  title: "Accessibility tree",
5357
- description: "Returns the accessibility hierarchy. mode='compact' (default) drops layout-only nodes (no testId/text/label and no interactive role), strips bounds/enabled/focused/non-interactive role, indexes duplicate testIds with @N suffix, and includes _meta at the root. mode='full' returns the raw tree.",
6542
+ description: (
6543
+ // P1: default mode is `outline` — compact custom-grammar text
6544
+ // (line-per-node, indent-based hierarchy, on_screen/off_screen
6545
+ // partition). 60-75% token reduction vs the older compact JSON.
6546
+ // `full` returns raw JSON tree with bounds + normalized short
6547
+ // roles (button, textfield, …) — escape hatch for debugging.
6548
+ // NB: descriptions stay self-contained — this MCP is consumed
6549
+ // from arbitrary user projects where our repo docs don't exist.
6550
+ `Returns the accessibility hierarchy. mode='outline' (default) \u2014 line-per-node text format, indent = hierarchy, sections 'on_screen:' / 'off_screen: { top|bottom|left|right }' / '_meta:', node syntax '- role "name" #testId {flags}', lowercase short roles (button, textfield, \u2026), 60-75% token saving vs JSON. mode='full' \u2014 raw JSON tree with bounds and normalized lowercase roles for debugging.`
6551
+ ),
5358
6552
  inputSchema: {
5359
- device: z3.string(),
5360
- mode: z3.enum(["compact", "full"]).optional()
6553
+ device: z2.string(),
6554
+ mode: z2.enum(["outline", "full"]).optional()
5361
6555
  }
5362
6556
  },
5363
6557
  async ({ device, mode }) => {
5364
- const m = mode ?? "compact";
6558
+ const m = mode ?? "outline";
5365
6559
  return this.tracked("a11y_tree", { device, mode: m }, async () => {
5366
6560
  const raw = await this.services.driver.a11yTree(device);
5367
- const out = this.services.treeInspector.withMode(raw, m);
5368
- return this.okJson(out);
6561
+ if (m === "full") {
6562
+ return this.okJson({ ...raw, _meta: { mode: "full" } });
6563
+ }
6564
+ const vp = await this.services.driver.windowSize(device);
6565
+ let alert;
6566
+ try {
6567
+ const text = await this.services.driver.readAlert(device);
6568
+ if (text && text.text !== "") {
6569
+ const buttons = await this.services.driver.readAlertButtons(device).catch(() => null);
6570
+ alert = { text: text.text, buttons };
6571
+ }
6572
+ } catch {
6573
+ }
6574
+ const semantic = this.services.treeInspector.semanticTree(raw, vp);
6575
+ if (alert) semantic.alert = alert;
6576
+ return this.ok(renderOutline(semantic));
5369
6577
  });
5370
6578
  }
5371
6579
  );
5372
6580
  }
5373
6581
  };
5374
- var TapTool = class extends BaseTool {
5375
- static {
5376
- __name(this, "TapTool");
5377
- }
5378
- register(server) {
5379
- server.registerTool(
5380
- "tap",
5381
- {
5382
- title: "Tap",
5383
- description: "Tap on a selector (testId | text | label | ordinal | pointPercent).",
5384
- inputSchema: { device: z3.string(), selector: SelectorParam }
5385
- },
5386
- async ({ device, selector }) => this.tracked("tap", { device, selector }, async () => {
5387
- await this.services.driver.tap(device, selector);
5388
- return this.ok(`tap ok on ${device}`);
5389
- })
5390
- );
5391
- }
5392
- };
5393
- var TypeTool = class extends BaseTool {
5394
- static {
5395
- __name(this, "TypeTool");
5396
- }
5397
- register(server) {
5398
- server.registerTool(
5399
- "type",
5400
- {
5401
- title: "Type text",
5402
- description: "Tap the input by selector and type text.",
5403
- inputSchema: { device: z3.string(), selector: SelectorParam, text: z3.string() }
5404
- },
5405
- async ({ device, selector, text }) => this.tracked("type", { device, selector, text }, async () => {
5406
- await this.services.driver.type(device, selector, text);
5407
- return this.ok(`type ok on ${device}`);
5408
- })
5409
- );
5410
- }
5411
- };
5412
- var PressKeyTool = class extends BaseTool {
5413
- static {
5414
- __name(this, "PressKeyTool");
5415
- }
5416
- register(server) {
5417
- server.registerTool(
5418
- "press_key",
5419
- {
5420
- title: "Press key",
5421
- description: "Press a system key: back | home | enter | escape. (iOS has no native back; the tool will surface a clear error.)",
5422
- inputSchema: {
5423
- device: z3.string(),
5424
- key: z3.enum(["back", "home", "enter", "escape"])
5425
- }
5426
- },
5427
- async ({ device, key }) => this.tracked("press_key", { device, key }, async () => {
5428
- await this.services.driver.pressKey(device, key);
5429
- return this.ok(`pressKey ${key} on ${device}`);
5430
- })
5431
- );
5432
- }
5433
- };
5434
- var SwipeTool = class extends BaseTool {
5435
- static {
5436
- __name(this, "SwipeTool");
5437
- }
5438
- register(server) {
5439
- server.registerTool(
5440
- "swipe",
5441
- {
5442
- title: "Swipe",
5443
- description: "Swipe in a direction, optionally anchored at a selector.",
5444
- inputSchema: {
5445
- device: z3.string(),
5446
- direction: z3.enum(["up", "down", "left", "right"]),
5447
- from: SelectorParamOptional
5448
- }
5449
- },
5450
- async ({ device, direction, from }) => this.tracked("swipe", { device, direction, from }, async () => {
5451
- await this.services.driver.swipe(device, direction, from);
5452
- return this.ok(`swipe ${direction} on ${device}`);
5453
- })
5454
- );
5455
- }
5456
- };
5457
- var OpenDeeplinkTool = class extends BaseTool {
5458
- static {
5459
- __name(this, "OpenDeeplinkTool");
5460
- }
5461
- register(server) {
5462
- server.registerTool(
5463
- "open_deeplink",
5464
- {
5465
- title: "Open deep link",
5466
- description: "Open a URL on the device (custom URL scheme / universal link).",
5467
- inputSchema: { device: z3.string(), url: z3.string() }
5468
- },
5469
- async ({ device, url }) => this.tracked("open_deeplink", { device, url }, async () => {
5470
- await this.services.driver.openDeeplink(device, url);
5471
- return this.ok(`open_deeplink on ${device}: ${url}`);
5472
- })
5473
- );
5474
- }
5475
- };
5476
- var AppLaunchTool = class extends BaseTool {
5477
- static {
5478
- __name(this, "AppLaunchTool");
5479
- }
5480
- register(server) {
5481
- server.registerTool(
5482
- "app_launch",
5483
- {
5484
- title: "Launch app",
5485
- description: "Launch the app under test; clean=true terminates the running process and starts cold (D-3 fix: no uninstall \u2014 preserves the dev-client).",
5486
- inputSchema: {
5487
- device: z3.string(),
5488
- bundleId: z3.string().optional(),
5489
- clean: z3.boolean().optional()
5490
- }
5491
- },
5492
- async ({ device, bundleId, clean }) => this.tracked("app_launch", { device, bundleId, clean }, async () => {
5493
- const opts = {};
5494
- if (bundleId !== void 0) opts.bundleId = bundleId;
5495
- if (clean !== void 0) opts.clean = clean;
5496
- await this.services.driver.appLaunch(device, opts);
5497
- return this.ok(`app_launch on ${device}${clean ? " (clean)" : ""}`);
5498
- })
5499
- );
5500
- }
5501
- };
5502
- var WaitForTool = class extends BaseTool {
5503
- static {
5504
- __name(this, "WaitForTool");
5505
- }
5506
- register(server) {
5507
- server.registerTool(
5508
- "wait_for",
5509
- {
5510
- title: "Wait for selector",
5511
- description: "Poll the a11y tree until a selector becomes visible.",
5512
- inputSchema: {
5513
- device: z3.string(),
5514
- selector: SelectorParam,
5515
- timeoutMs: z3.number().int().positive().optional(),
5516
- optional: z3.boolean().optional()
5517
- }
5518
- },
5519
- async ({ device, selector, timeoutMs, optional }) => this.tracked("wait_for", { device, selector, timeoutMs, optional }, async () => {
5520
- const opts = {};
5521
- if (timeoutMs !== void 0) opts.timeoutMs = timeoutMs;
5522
- if (optional !== void 0) opts.optional = optional;
5523
- const visible = await this.services.driver.waitFor(device, selector, opts);
5524
- return this.ok(`wait_for ${visible ? "visible" : "absent"} on ${device}`);
5525
- })
5526
- );
5527
- }
5528
- };
5529
6582
  var SessionResetTool = class extends BaseTool {
5530
6583
  static {
5531
6584
  __name(this, "SessionResetTool");
@@ -5547,14 +6600,14 @@ var SessionResetTool = class extends BaseTool {
5547
6600
  };
5548
6601
 
5549
6602
  // src/mcp/tools/resolve-selector.tool.ts
5550
- import { z as z4 } from "zod";
5551
- var SelectorInput = z4.preprocess(
6603
+ import { z as z3 } from "zod";
6604
+ var SelectorInput = z3.preprocess(
5552
6605
  (v) => typeof v === "string" ? safeJsonParse(v) : v,
5553
- z4.object({
5554
- testId: z4.string().optional(),
5555
- text: z4.string().optional(),
5556
- label: z4.string().optional(),
5557
- ordinal: z4.number().int().nonnegative().optional()
6606
+ z3.object({
6607
+ testId: z3.string().optional(),
6608
+ text: z3.string().optional(),
6609
+ label: z3.string().optional(),
6610
+ ordinal: z3.number().int().nonnegative().optional()
5558
6611
  })
5559
6612
  );
5560
6613
  function safeJsonParse(s) {
@@ -5576,11 +6629,11 @@ var ResolveSelectorTool = class extends BaseTool {
5576
6629
  title: "Resolve selector against current screen",
5577
6630
  description: "Returns either the matched A11yNode or up to 3 near-misses (with score + reason). Use this when a tap/type fails to understand which element is actually on screen. Selector shape: {testId?, text?, label?, ordinal?}.",
5578
6631
  inputSchema: {
5579
- device: z4.string(),
6632
+ device: z3.string(),
5580
6633
  selector: SelectorInput
5581
6634
  }
5582
6635
  },
5583
- async ({ device, selector }) => this.safe(async () => {
6636
+ async ({ device, selector }) => this.tracked("resolve_selector", { device, selector }, async () => {
5584
6637
  const tree = await this.services.driver.a11yTree(device);
5585
6638
  const r = this.services.selectorResolver.resolve(tree, selector);
5586
6639
  if (r.ok) {
@@ -5603,6 +6656,345 @@ var ResolveSelectorTool = class extends BaseTool {
5603
6656
  }
5604
6657
  };
5605
6658
 
6659
+ // src/mcp/tools/app-install.tool.ts
6660
+ import { resolve as resolve5 } from "path";
6661
+ import { z as z4 } from "zod";
6662
+
6663
+ // src/runner/install/install-app.ts
6664
+ import { execFile as execFile5 } from "child_process";
6665
+ import { existsSync as existsSync4, statSync as statSync2 } from "fs";
6666
+ import { resolve as resolve4 } from "path";
6667
+ import { promisify as promisify5 } from "util";
6668
+
6669
+ // src/runner/install/info-plist.ts
6670
+ import { execFile as execFile4 } from "child_process";
6671
+ import { join as join5 } from "path";
6672
+ import { promisify as promisify4 } from "util";
6673
+ var exec4 = promisify4(execFile4);
6674
+ async function readInfoPlist(appPath) {
6675
+ const plistPath = join5(appPath, "Info.plist");
6676
+ const { stdout } = await exec4("plutil", ["-convert", "json", "-o", "-", plistPath]);
6677
+ const raw = JSON.parse(stdout);
6678
+ const bundleId = typeof raw.CFBundleIdentifier === "string" ? raw.CFBundleIdentifier : void 0;
6679
+ const urlSchemes = [];
6680
+ if (Array.isArray(raw.CFBundleURLTypes)) {
6681
+ for (const type of raw.CFBundleURLTypes) {
6682
+ if (type && typeof type === "object" && Array.isArray(type.CFBundleURLSchemes)) {
6683
+ for (const scheme of type.CFBundleURLSchemes) {
6684
+ if (typeof scheme === "string") urlSchemes.push(scheme);
6685
+ }
6686
+ }
6687
+ }
6688
+ }
6689
+ const usageDescriptions = [];
6690
+ for (const [key, value] of Object.entries(raw)) {
6691
+ if (key.startsWith("NS") && key.endsWith("UsageDescription") && typeof value === "string") {
6692
+ usageDescriptions.push({ key, description: value });
6693
+ }
6694
+ }
6695
+ return { ...bundleId ? { bundleId } : {}, urlSchemes, usageDescriptions };
6696
+ }
6697
+ __name(readInfoPlist, "readInfoPlist");
6698
+
6699
+ // src/runner/install/permission-mapper.ts
6700
+ var NS_TO_SIMCTL_SERVICE = Object.freeze({
6701
+ // Location
6702
+ NSLocationWhenInUseUsageDescription: "location",
6703
+ NSLocationAlwaysAndWhenInUseUsageDescription: "location-always",
6704
+ NSLocationAlwaysUsageDescription: "location-always",
6705
+ // Media
6706
+ NSPhotoLibraryUsageDescription: "photos",
6707
+ NSPhotoLibraryAddUsageDescription: "photos-add",
6708
+ NSMicrophoneUsageDescription: "microphone",
6709
+ NSMediaLibraryUsageDescription: "media-library",
6710
+ // Personal data
6711
+ NSContactsUsageDescription: "contacts",
6712
+ NSCalendarsUsageDescription: "calendar",
6713
+ // legacy (pre-iOS 17)
6714
+ NSCalendarsFullAccessUsageDescription: "calendar",
6715
+ // iOS 17+ preferred
6716
+ NSCalendarsWriteOnlyAccessUsageDescription: "calendar",
6717
+ // iOS 17+ write-only
6718
+ NSRemindersUsageDescription: "reminders",
6719
+ // Sensors / device
6720
+ NSMotionUsageDescription: "motion",
6721
+ // Siri
6722
+ NSSiriUsageDescription: "siri",
6723
+ // Camera + push notifications are not exposed via `simctl privacy` —
6724
+ // camera needs the alert-dismiss path (B1), push needs UNUserNotificationCenter.
6725
+ NSCameraUsageDescription: null
6726
+ });
6727
+ var KNOWN_SIMCTL_SERVICES = new Set(
6728
+ Object.values(NS_TO_SIMCTL_SERVICE).filter((v) => v !== null)
6729
+ );
6730
+ function inferSimctlServices(usageDescriptions) {
6731
+ const seen = /* @__PURE__ */ new Set();
6732
+ const out = [];
6733
+ for (const u of usageDescriptions) {
6734
+ const svc = NS_TO_SIMCTL_SERVICE[u.key];
6735
+ if (svc !== void 0 && svc !== null && !seen.has(svc)) {
6736
+ seen.add(svc);
6737
+ out.push(svc);
6738
+ }
6739
+ }
6740
+ return out;
6741
+ }
6742
+ __name(inferSimctlServices, "inferSimctlServices");
6743
+
6744
+ // src/runner/install/install-app.ts
6745
+ var exec5 = promisify5(execFile5);
6746
+ async function installApp2(opts, deps) {
6747
+ const appPathAbsolute = resolve4(opts.appPath);
6748
+ if (!existsSync4(appPathAbsolute)) {
6749
+ throw new Error(`App path does not exist: ${appPathAbsolute}`);
6750
+ }
6751
+ if (!appPathAbsolute.endsWith(".app")) {
6752
+ throw new Error(
6753
+ `App path must point to a .app bundle (directory ending in .app), got: ${appPathAbsolute}. If you have an .ipa or .zip \u2014 extract it first.`
6754
+ );
6755
+ }
6756
+ if (!statSync2(appPathAbsolute).isDirectory()) {
6757
+ throw new Error(`App path must be a directory (.app bundle), got file: ${appPathAbsolute}`);
6758
+ }
6759
+ const infoPlist = `${appPathAbsolute}/Info.plist`;
6760
+ if (!existsSync4(infoPlist)) {
6761
+ throw new Error(`Info.plist not found inside .app: ${infoPlist}`);
6762
+ }
6763
+ const readBundleId = deps.readBundleId ?? readBundleIdViaPlistBuddy;
6764
+ const appBundleId = (await readBundleId(appPathAbsolute)).trim();
6765
+ if (!appBundleId) {
6766
+ throw new Error(`Could not read CFBundleIdentifier from ${infoPlist}`);
6767
+ }
6768
+ const readUrlScheme = deps.readUrlScheme ?? readUrlSchemeViaPlistBuddy;
6769
+ const appUrlScheme = await readUrlScheme(appPathAbsolute);
6770
+ const readUsageDescriptions = deps.readUsageDescriptions ?? (async (p) => (await readInfoPlist(p)).usageDescriptions);
6771
+ const usageDescriptions = await readUsageDescriptions(appPathAbsolute);
6772
+ const detectedPermissions = inferSimctlServices(usageDescriptions);
6773
+ if (opts.slots.length === 0) {
6774
+ throw new Error(`No slots to install on. Pass --slot or check SIM_POOL in unotest/.env.`);
6775
+ }
6776
+ for (const slot of opts.slots) {
6777
+ if (!opts.simBySlot[slot]) {
6778
+ throw new Error(
6779
+ `Slot "${slot}" requested but no SIM_${slot}_NAME in unotest/.env (or not in SIM_POOL).`
6780
+ );
6781
+ }
6782
+ }
6783
+ const slotResults = [];
6784
+ for (const slot of opts.slots) {
6785
+ const simName = opts.simBySlot[slot];
6786
+ const sim = await deps.simctl.resolveByName(simName);
6787
+ let erased = false;
6788
+ if (opts.erase) {
6789
+ deps.logger.info(`[${slot}] erasing ${simName} (${sim.udid})`);
6790
+ try {
6791
+ await deps.simctl.shutdown(sim.udid);
6792
+ } catch {
6793
+ }
6794
+ await deps.simctl.erase(sim.udid);
6795
+ erased = true;
6796
+ }
6797
+ deps.logger.info(`[${slot}] booting ${simName} (${sim.udid})`);
6798
+ await deps.simctl.boot(sim.udid);
6799
+ if (process.env.SIMCTL_HEADLESS !== "1") {
6800
+ await deps.simctl.openSimulatorApp();
6801
+ }
6802
+ let uninstalled = false;
6803
+ if (opts.clean) {
6804
+ deps.logger.info(`[${slot}] uninstalling existing ${appBundleId}`);
6805
+ try {
6806
+ await deps.simctl.uninstall(sim.udid, appBundleId);
6807
+ uninstalled = true;
6808
+ } catch {
6809
+ }
6810
+ deps.logger.info(`[${slot}] resetting keychain on ${sim.udid}`);
6811
+ await deps.simctl.keychainReset(sim.udid);
6812
+ }
6813
+ deps.logger.info(`[${slot}] installing ${appPathAbsolute}`);
6814
+ await deps.simctl.install(sim.udid, appPathAbsolute);
6815
+ if (opts.permissions && opts.permissions.length > 0) {
6816
+ for (const service of opts.permissions) {
6817
+ deps.logger.info(`[${slot}] granting ${service} to ${appBundleId}`);
6818
+ await deps.simctl.privacyGrant(sim.udid, service, appBundleId);
6819
+ }
6820
+ }
6821
+ if (opts.pinKeyboard !== false) {
6822
+ deps.logger.info(`[${slot}] pinning keyboard to en_US@QWERTY`);
6823
+ await deps.simctl.pinEnglishKeyboard(sim.udid);
6824
+ }
6825
+ let launched = false;
6826
+ if (opts.launch) {
6827
+ deps.logger.info(`[${slot}] launching ${appBundleId}`);
6828
+ const { pid } = await deps.simctl.launch(sim.udid, appBundleId);
6829
+ await deps.simctl.assertLaunchedAndStable(sim.udid, appBundleId, pid);
6830
+ launched = true;
6831
+ }
6832
+ slotResults.push({ slot, simName, udid: sim.udid, erased, uninstalled, launched });
6833
+ }
6834
+ return {
6835
+ appBundleId,
6836
+ appUrlScheme,
6837
+ appPathAbsolute,
6838
+ bundleIdMismatch: opts.envBundleId !== void 0 && opts.envBundleId !== appBundleId,
6839
+ detectedPermissions,
6840
+ slots: slotResults
6841
+ };
6842
+ }
6843
+ __name(installApp2, "installApp");
6844
+ async function readBundleIdViaPlistBuddy(appPath) {
6845
+ const { stdout } = await exec5("/usr/libexec/PlistBuddy", [
6846
+ "-c",
6847
+ "Print :CFBundleIdentifier",
6848
+ `${appPath}/Info.plist`
6849
+ ]);
6850
+ return stdout;
6851
+ }
6852
+ __name(readBundleIdViaPlistBuddy, "readBundleIdViaPlistBuddy");
6853
+ async function readUrlSchemeViaPlistBuddy(appPath) {
6854
+ try {
6855
+ const { stdout } = await exec5("/usr/libexec/PlistBuddy", [
6856
+ "-c",
6857
+ "Print :CFBundleURLTypes:0:CFBundleURLSchemes:0",
6858
+ `${appPath}/Info.plist`
6859
+ ]);
6860
+ const scheme = stdout.trim();
6861
+ return scheme.length > 0 ? scheme : void 0;
6862
+ } catch {
6863
+ return void 0;
6864
+ }
6865
+ }
6866
+ __name(readUrlSchemeViaPlistBuddy, "readUrlSchemeViaPlistBuddy");
6867
+
6868
+ // src/runner/install/update-env-file.ts
6869
+ import { existsSync as existsSync5, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "fs";
6870
+ function updateEnvFile(path, updates) {
6871
+ const original = existsSync5(path) ? readFileSync4(path, "utf8") : "";
6872
+ const lines = original.split(/\r?\n/);
6873
+ const added = [];
6874
+ const changed = [];
6875
+ const unchanged = [];
6876
+ for (const { key, value } of updates) {
6877
+ const newLine = `${key}=${value}`;
6878
+ const re = new RegExp(`^\\s*${escapeRegex(key)}\\s*=`);
6879
+ let foundAt = -1;
6880
+ for (let i = 0; i < lines.length; i++) {
6881
+ if (re.test(lines[i] ?? "")) {
6882
+ foundAt = i;
6883
+ break;
6884
+ }
6885
+ }
6886
+ if (foundAt === -1) {
6887
+ if (lines.length > 0 && lines[lines.length - 1] !== "") lines.push("");
6888
+ lines.push(newLine);
6889
+ added.push(key);
6890
+ } else if (lines[foundAt] === newLine) {
6891
+ unchanged.push(key);
6892
+ } else {
6893
+ lines[foundAt] = newLine;
6894
+ changed.push(key);
6895
+ }
6896
+ }
6897
+ if (added.length > 0 || changed.length > 0) {
6898
+ writeFileSync3(path, lines.join("\n"));
6899
+ }
6900
+ return { added, changed, unchanged };
6901
+ }
6902
+ __name(updateEnvFile, "updateEnvFile");
6903
+ function escapeRegex(s) {
6904
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
6905
+ }
6906
+ __name(escapeRegex, "escapeRegex");
6907
+
6908
+ // src/mcp/tools/app-install.tool.ts
6909
+ var AppInstallTool = class extends BaseTool {
6910
+ static {
6911
+ __name(this, "AppInstallTool");
6912
+ }
6913
+ register(server) {
6914
+ server.registerTool(
6915
+ "app_install",
6916
+ {
6917
+ title: "Install iOS app on simulator",
6918
+ description: "Installs a .app bundle on the configured simulator slot(s). If `path` is omitted, falls back to APP_PATH in unotest/.env. If neither is set, returns an error instructing you to ask the user for the .app path, then call again with `path` + `updateEnv: true`. Use this tool when a scenario fails with `App ... is not installed on ...`.",
6919
+ inputSchema: {
6920
+ path: z4.string().optional().describe("Absolute or relative path to a .app bundle. Falls back to APP_PATH env."),
6921
+ slot: z4.enum(["A", "B", "all"]).optional().describe("Slot to install on. Default: 'A'."),
6922
+ clean: z4.boolean().optional().describe(
6923
+ "Uninstall existing app AND wipe the simulator keychain before reinstall. Use this when scenarios need a fresh signed-out state \u2014 `simctl uninstall` alone leaves auth tokens behind, so without keychain reset the next launch may still land on a logged-in screen."
6924
+ ),
6925
+ erase: z4.boolean().optional().describe("Erase simulator content & settings before install (destructive)."),
6926
+ launch: z4.boolean().optional().describe("Launch the app after install for a quick sanity check."),
6927
+ updateEnv: z4.boolean().optional().describe(
6928
+ "Persist `path` as APP_PATH in unotest/.env. Also syncs APP_BUNDLE_ID if mismatched."
6929
+ )
6930
+ }
6931
+ },
6932
+ async ({ path, slot, clean, erase, launch, updateEnv }) => this.tracked("app_install", { path, slot, clean, erase, launch, updateEnv }, async () => {
6933
+ const env = this.services.envConfig;
6934
+ const appPath = path ?? process.env.APP_PATH;
6935
+ if (!appPath) {
6936
+ return this.failJson({
6937
+ error: "missing-app-path",
6938
+ message: "No path provided and APP_PATH is not set in unotest/.env. Ask the user for the path to the .app bundle, then call this tool again with `path: '<path>'` and `updateEnv: true` to persist it."
6939
+ });
6940
+ }
6941
+ const slotChoice = slot ?? "A";
6942
+ const slots = slotChoice === "all" ? env.simPool : env.simPool.includes(slotChoice) ? [slotChoice] : null;
6943
+ if (slots === null) {
6944
+ return this.failJson({
6945
+ error: "slot-not-in-pool",
6946
+ message: `Slot "${slotChoice}" is not in SIM_POOL (${env.simPool.join(",")}). Adjust SIM_POOL or pick a slot in the pool.`
6947
+ });
6948
+ }
6949
+ let permissions = env.APP_PERMISSIONS && env.APP_PERMISSIONS.trim().length > 0 ? env.APP_PERMISSIONS.split(",").map((s) => s.trim()).filter(Boolean) : void 0;
6950
+ if (permissions === void 0 && updateEnv) {
6951
+ try {
6952
+ const plist = await readInfoPlist(resolve5(appPath));
6953
+ const detected = inferSimctlServices(plist.usageDescriptions);
6954
+ if (detected.length > 0) permissions = detected;
6955
+ } catch {
6956
+ }
6957
+ }
6958
+ const result = await installApp2(
6959
+ {
6960
+ appPath,
6961
+ slots,
6962
+ simBySlot: env.simBySlot,
6963
+ envBundleId: env.APP_BUNDLE_ID,
6964
+ clean,
6965
+ erase,
6966
+ launch,
6967
+ ...permissions !== void 0 ? { permissions } : {}
6968
+ },
6969
+ { simctl: new SimctlAdapter(), logger: this.services.logger.child("install") }
6970
+ );
6971
+ let envWrite;
6972
+ if (updateEnv) {
6973
+ const updates = [{ key: "APP_PATH", value: result.appPathAbsolute }];
6974
+ if (result.bundleIdMismatch) {
6975
+ updates.push({ key: "APP_BUNDLE_ID", value: result.appBundleId });
6976
+ }
6977
+ if (permissions !== void 0 && permissions.length > 0 && permissions.join(",") !== (env.APP_PERMISSIONS ?? "")) {
6978
+ updates.push({ key: "APP_PERMISSIONS", value: permissions.join(",") });
6979
+ }
6980
+ envWrite = updateEnvFile(resolve5("unotest/.env"), updates);
6981
+ }
6982
+ return this.okJson({
6983
+ appBundleId: result.appBundleId,
6984
+ appPathAbsolute: result.appPathAbsolute,
6985
+ bundleIdMismatch: result.bundleIdMismatch,
6986
+ envBundleId: env.APP_BUNDLE_ID ?? null,
6987
+ detectedPermissions: result.detectedPermissions,
6988
+ grantedPermissions: permissions ?? null,
6989
+ slots: result.slots,
6990
+ envWrite: envWrite ?? null,
6991
+ hint: result.bundleIdMismatch && !updateEnv ? "App bundle id differs from APP_BUNDLE_ID in .env \u2014 call again with updateEnv:true to sync, or update .env manually." : void 0
6992
+ });
6993
+ })
6994
+ );
6995
+ }
6996
+ };
6997
+
5606
6998
  // src/mcp/tools/debugger.tool.ts
5607
6999
  import { z as z5 } from "zod";
5608
7000
 
@@ -5747,7 +7139,7 @@ var TestRuntime = class {
5747
7139
  };
5748
7140
 
5749
7141
  // src/mcp/tools/debugger.tool.ts
5750
- import { randomUUID } from "crypto";
7142
+ import { randomUUID as randomUUID2 } from "crypto";
5751
7143
  var RunTestTool = class extends BaseTool {
5752
7144
  static {
5753
7145
  __name(this, "RunTestTool");
@@ -5766,7 +7158,7 @@ var RunTestTool = class extends BaseTool {
5766
7158
  maxDurationMs: z5.number().int().positive().optional()
5767
7159
  }
5768
7160
  },
5769
- async ({ name, mode, pauseOnFailure, maxSteps, maxDurationMs }) => this.safe(async () => {
7161
+ async ({ name, mode, pauseOnFailure, maxSteps, maxDurationMs }) => this.tracked("run_test", { name, mode, pauseOnFailure, maxSteps, maxDurationMs }, async () => {
5770
7162
  const { scenarioLoader, dslLinter, astExecutor, testRuntimeManager, envConfig, logger, driver, selectorResolver, treeInspector, failureExplainer, screenshotAnnotator, actionDiffer, db, api, shell: shell2 } = this.services;
5771
7163
  const loaded2 = await scenarioLoader.load(name);
5772
7164
  const entryDiags = dslLinter.lint(loaded2.ast, loaded2.source.source, {
@@ -5788,7 +7180,7 @@ var RunTestTool = class extends BaseTool {
5788
7180
  message: `Entry function "${testFn}" not found in ${name}.js. Convention: unotest/e2e/${name}.js \u2192 function ${testFn}().`
5789
7181
  });
5790
7182
  }
5791
- const runId = `e2e-${randomUUID().slice(0, 8)}`;
7183
+ const runId = `e2e-${randomUUID2().slice(0, 8)}`;
5792
7184
  const runtime = new TestRuntime({
5793
7185
  runId,
5794
7186
  envConfig,
@@ -5842,7 +7234,7 @@ var StepTool = class extends BaseTool {
5842
7234
  description: "After paused-step or paused-failure, advances the executor by one statement. On failure-retry: re-runs the failed statement.",
5843
7235
  inputSchema: { runtimeId: z5.string() }
5844
7236
  },
5845
- async ({ runtimeId }) => this.safe(async () => {
7237
+ async ({ runtimeId }) => this.tracked("step", { runtimeId }, async () => {
5846
7238
  const next = await this.services.testRuntimeManager.step(runtimeId);
5847
7239
  return this.okJson({ next });
5848
7240
  })
@@ -5861,7 +7253,7 @@ var ResumeTool = class extends BaseTool {
5861
7253
  description: "After paused-failure: re-executes the failed statement (D-17 Resume semantics) and continues until completion or next failure. After paused-step: advances and continues.",
5862
7254
  inputSchema: { runtimeId: z5.string() }
5863
7255
  },
5864
- async ({ runtimeId }) => this.safe(async () => {
7256
+ async ({ runtimeId }) => this.tracked("resume", { runtimeId }, async () => {
5865
7257
  const next = await this.services.testRuntimeManager.resume(runtimeId);
5866
7258
  return this.okJson({ next });
5867
7259
  })
@@ -5880,7 +7272,7 @@ var InspectRuntimeTool = class extends BaseTool {
5880
7272
  description: "Returns vars, current device, last event, last result for the given runtimeId. Resets the TTL timer.",
5881
7273
  inputSchema: { runtimeId: z5.string() }
5882
7274
  },
5883
- async ({ runtimeId }) => this.safe(async () => {
7275
+ async ({ runtimeId }) => this.tracked("inspect_runtime", { runtimeId }, async () => {
5884
7276
  return this.okJson(this.services.testRuntimeManager.inspect(runtimeId));
5885
7277
  })
5886
7278
  );
@@ -5898,7 +7290,7 @@ var AbortRuntimeTool = class extends BaseTool {
5898
7290
  description: "Cleanly stops the generator and runs onAbort cleanup. After this call the runtime is forgotten.",
5899
7291
  inputSchema: { runtimeId: z5.string() }
5900
7292
  },
5901
- async ({ runtimeId }) => this.safe(async () => {
7293
+ async ({ runtimeId }) => this.tracked("abort_runtime", { runtimeId }, async () => {
5902
7294
  await this.services.testRuntimeManager.abort(runtimeId);
5903
7295
  return this.okJson({ status: "aborted", runtimeId });
5904
7296
  })
@@ -5917,33 +7309,488 @@ var ListRuntimesTool = class extends BaseTool {
5917
7309
  description: "Useful when several tests have been kicked off \u2014 find the one that failed and is awaiting inspection.",
5918
7310
  inputSchema: {}
5919
7311
  },
5920
- async () => this.safe(async () => {
7312
+ async () => this.tracked("list_runtimes", {}, async () => {
5921
7313
  return this.okJson({ runtimes: this.services.testRuntimeManager.list() });
5922
7314
  })
5923
7315
  );
5924
7316
  }
5925
7317
  };
5926
7318
 
7319
+ // src/mcp/tools/explore.tool.ts
7320
+ import { z as z7 } from "zod";
7321
+
7322
+ // src/mcp/tools/selector-param.ts
7323
+ import { z as z6 } from "zod";
7324
+ var SelectorShape = {
7325
+ testId: z6.string().optional(),
7326
+ text: z6.string().optional(),
7327
+ label: z6.string().optional(),
7328
+ ordinal: z6.number().int().nonnegative().optional(),
7329
+ pointPercent: z6.object({
7330
+ x: z6.number().min(0).max(1),
7331
+ y: z6.number().min(0).max(1)
7332
+ }).optional()
7333
+ };
7334
+ function coerceJsonObject(v) {
7335
+ if (typeof v !== "string") return v;
7336
+ try {
7337
+ return JSON.parse(v);
7338
+ } catch {
7339
+ return v;
7340
+ }
7341
+ }
7342
+ __name(coerceJsonObject, "coerceJsonObject");
7343
+ var SelectorParam = z6.preprocess(
7344
+ coerceJsonObject,
7345
+ z6.object(SelectorShape).refine(
7346
+ (s) => Boolean(s.testId || s.text || s.label || s.pointPercent),
7347
+ { message: "selector requires one of testId/text/label/pointPercent" }
7348
+ )
7349
+ );
7350
+ var SelectorParamOptional = z6.preprocess(coerceJsonObject, z6.object(SelectorShape).optional());
7351
+
7352
+ // src/mcp/tools/explore.tool.ts
7353
+ var ACTION_ENUM = [
7354
+ "tap",
7355
+ "type",
7356
+ "press_key",
7357
+ "swipe",
7358
+ "wait_for",
7359
+ "app_launch",
7360
+ "open_deeplink",
7361
+ "accept_alert",
7362
+ "dismiss_alert"
7363
+ ];
7364
+ var KEY_ENUM = ["back", "home", "enter", "escape"];
7365
+ var DIRECTION_ENUM = ["up", "down", "left", "right"];
7366
+ var stepShape = {
7367
+ explorationId: z7.string().optional(),
7368
+ action: z7.enum(ACTION_ENUM),
7369
+ device: z7.string().optional(),
7370
+ selector: SelectorParamOptional,
7371
+ value: z7.string().optional(),
7372
+ key: z7.enum(KEY_ENUM).optional(),
7373
+ direction: z7.enum(DIRECTION_ENUM).optional(),
7374
+ from: SelectorParamOptional,
7375
+ url: z7.string().optional(),
7376
+ bundleId: z7.string().optional(),
7377
+ clean: z7.boolean().optional(),
7378
+ timeoutMs: z7.number().int().positive().optional(),
7379
+ optional: z7.boolean().optional(),
7380
+ button: z7.string().optional(),
7381
+ description: z7.string().optional(),
7382
+ section: z7.string().optional()
7383
+ };
7384
+ function validateRecordingArgs(args) {
7385
+ if (!args.section) return "section required when recording";
7386
+ if (!args.description) return "description required when recording";
7387
+ const missing = missingRequiredFields(args);
7388
+ if (missing) return missing;
7389
+ if (args.action === "wait_for" && args.optional === true) {
7390
+ return "wait_for { optional: true } cannot be recorded: DSL waitFor doesn't support optional semantics, so the generated test would diverge from the recorded behavior. Use ad-hoc (omit explorationId) or fold the optional case into a different action.";
7391
+ }
7392
+ return null;
7393
+ }
7394
+ __name(validateRecordingArgs, "validateRecordingArgs");
7395
+ function missingRequiredFields(a) {
7396
+ switch (a.action) {
7397
+ case "tap":
7398
+ return a.selector ? null : "tap requires selector";
7399
+ case "type":
7400
+ if (!a.selector) return "type requires selector";
7401
+ if (a.value === void 0) return "type requires value";
7402
+ return null;
7403
+ case "press_key":
7404
+ return a.key ? null : "press_key requires key";
7405
+ case "swipe":
7406
+ return a.direction ? null : "swipe requires direction";
7407
+ case "wait_for":
7408
+ return a.selector ? null : "wait_for requires selector";
7409
+ case "open_deeplink":
7410
+ return a.url ? null : "open_deeplink requires url";
7411
+ case "app_launch":
7412
+ case "accept_alert":
7413
+ case "dismiss_alert":
7414
+ return null;
7415
+ }
7416
+ }
7417
+ __name(missingRequiredFields, "missingRequiredFields");
7418
+ async function runDriverAction(driver, device, a) {
7419
+ switch (a.action) {
7420
+ case "tap":
7421
+ await driver.tap(device, a.selector);
7422
+ return {};
7423
+ case "type":
7424
+ await driver.type(device, a.selector, a.value);
7425
+ return {};
7426
+ case "press_key":
7427
+ await driver.pressKey(device, a.key);
7428
+ return {};
7429
+ case "swipe":
7430
+ await driver.swipe(device, a.direction, a.from);
7431
+ return {};
7432
+ case "wait_for": {
7433
+ const opts = {};
7434
+ if (a.timeoutMs !== void 0) opts.timeoutMs = a.timeoutMs;
7435
+ if (a.optional !== void 0) opts.optional = a.optional;
7436
+ const visible = await driver.waitFor(device, a.selector, opts);
7437
+ return { visible };
7438
+ }
7439
+ case "app_launch": {
7440
+ const opts = {};
7441
+ if (a.bundleId !== void 0) opts.bundleId = a.bundleId;
7442
+ if (a.clean !== void 0) opts.clean = a.clean;
7443
+ await driver.appLaunch(device, opts);
7444
+ return {};
7445
+ }
7446
+ case "open_deeplink":
7447
+ await driver.openDeeplink(device, a.url);
7448
+ return {};
7449
+ case "accept_alert":
7450
+ await driver.acceptAlert(device, a.button);
7451
+ return {};
7452
+ case "dismiss_alert":
7453
+ await driver.dismissAlert(device);
7454
+ return {};
7455
+ }
7456
+ }
7457
+ __name(runDriverAction, "runDriverAction");
7458
+ var ExploreStartTool = class extends BaseTool {
7459
+ static {
7460
+ __name(this, "ExploreStartTool");
7461
+ }
7462
+ register(server) {
7463
+ server.registerTool(
7464
+ "explore_start",
7465
+ {
7466
+ title: "Start a recording session",
7467
+ description: "Begins an ExplorationSession. Returns { explorationId }. Subsequent explore_step calls that include this id will be recorded into an ActionLog; calls without it are ad-hoc. Device slot ('A' or 'B') is fixed for the session. No auto-launch: the first explore_step must do the app_launch / open_deeplink so it appears in the generated test.",
7468
+ inputSchema: {
7469
+ scenario_name: z7.string().min(1).describe("Logical name; later used by save_exploration_as_test as the test file name."),
7470
+ device: z7.string().min(1).describe("Slot from SIM_POOL, e.g. 'A'."),
7471
+ title: z7.string().optional(),
7472
+ description: z7.string().optional()
7473
+ }
7474
+ },
7475
+ async ({ scenario_name, device, title, description }) => this.tracked(
7476
+ "explore_start",
7477
+ { scenario_name, device, title, description },
7478
+ async () => {
7479
+ const session = this.services.exploration.startSession({
7480
+ scenarioName: scenario_name,
7481
+ device,
7482
+ ...title !== void 0 ? { title } : {},
7483
+ ...description !== void 0 ? { description } : {}
7484
+ });
7485
+ return this.okJson({
7486
+ explorationId: session.explorationId,
7487
+ scenarioName: session.scenarioName,
7488
+ device: session.device,
7489
+ status: session.status,
7490
+ startedAt: session.startedAt
7491
+ });
7492
+ }
7493
+ )
7494
+ );
7495
+ }
7496
+ };
7497
+ var ExploreStopTool = class extends BaseTool {
7498
+ static {
7499
+ __name(this, "ExploreStopTool");
7500
+ }
7501
+ register(server) {
7502
+ server.registerTool(
7503
+ "explore_stop",
7504
+ {
7505
+ title: "Stop a recording session",
7506
+ description: "Marks the session as stopped. The device stays running (no teardown). After stop, generate_dsl_from_exploration / save_exploration_as_test remain callable; further explore_step calls with this id will fail.",
7507
+ inputSchema: {
7508
+ explorationId: z7.string().min(1)
7509
+ }
7510
+ },
7511
+ async ({ explorationId }) => this.tracked("explore_stop", { explorationId }, async () => {
7512
+ const session = this.services.exploration.getSession(explorationId);
7513
+ if (!session) return this.fail(`exploration session not found: ${explorationId}`);
7514
+ const log = this.services.exploration.getLog(explorationId);
7515
+ this.services.exploration.stopSession(explorationId);
7516
+ return this.okJson({
7517
+ stepCount: log.entries.length,
7518
+ readyForConversion: true
7519
+ });
7520
+ })
7521
+ );
7522
+ }
7523
+ };
7524
+ var ExploreStateTool = class extends BaseTool {
7525
+ static {
7526
+ __name(this, "ExploreStateTool");
7527
+ }
7528
+ register(server) {
7529
+ server.registerTool(
7530
+ "explore_state",
7531
+ {
7532
+ title: "Inspect the recording session log",
7533
+ description: "Returns the session status and all recorded ActionEntries. Each entry carries its per-entry `stability` annotation (stable | fragile). The full warnings list (FRAGILE_LOCATOR / NO_DSL_PRIMITIVE / BUNDLE_ID_IGNORED) is NOT returned here \u2014 it is derived on demand by generate_dsl_from_exploration.",
7534
+ inputSchema: {
7535
+ explorationId: z7.string().min(1)
7536
+ }
7537
+ },
7538
+ async ({ explorationId }) => this.tracked("explore_state", { explorationId }, async () => {
7539
+ const session = this.services.exploration.getSession(explorationId);
7540
+ if (!session) return this.fail(`exploration session not found: ${explorationId}`);
7541
+ const log = this.services.exploration.getLog(explorationId);
7542
+ return this.okJson({
7543
+ session: {
7544
+ explorationId: session.explorationId,
7545
+ scenarioName: session.scenarioName,
7546
+ device: session.device,
7547
+ status: session.status,
7548
+ startedAt: session.startedAt,
7549
+ ...session.title !== void 0 ? { title: session.title } : {}
7550
+ },
7551
+ entries: log.entries
7552
+ });
7553
+ })
7554
+ );
7555
+ }
7556
+ };
7557
+ var ExploreStepTool = class extends BaseTool {
7558
+ static {
7559
+ __name(this, "ExploreStepTool");
7560
+ }
7561
+ register(server) {
7562
+ server.registerTool(
7563
+ "explore_step",
7564
+ {
7565
+ title: "Execute (and optionally record) a UI action",
7566
+ description: "Universal action runner. With explorationId \u2192 executes AND records the action into the session log (section + description required). Without explorationId \u2192 ad-hoc execution, nothing recorded. Failed actions are never recorded. action: tap | type | press_key | swipe | wait_for | app_launch | open_deeplink | accept_alert | dismiss_alert. Required per action: tap/type/wait_for \u2192 selector; type \u2192 +value; press_key \u2192 key; swipe \u2192 direction (from optional anchor); open_deeplink \u2192 url. app_launch and *_alert have no required fields. app_launch { clean: true } terminates the running app, wipes the simulator keychain (B5 \u2014 auth tokens otherwise survive a clean launch), then starts cold. Recording-time reject: wait_for { optional: true } \u2014 DSL waitFor has no optional semantics; use it ad-hoc instead.",
7567
+ inputSchema: stepShape
7568
+ },
7569
+ async (rawArgs) => this.tracked("explore_step", rawArgs, async () => {
7570
+ const args = rawArgs;
7571
+ const session = args.explorationId ? this.services.exploration.getSession(args.explorationId) : null;
7572
+ if (args.explorationId && !session) {
7573
+ return this.fail(`exploration session not found: ${args.explorationId}`);
7574
+ }
7575
+ const willRecord = session?.status === "active";
7576
+ if (args.explorationId && session && !willRecord) {
7577
+ return this.fail(
7578
+ `exploration session ${args.explorationId} is stopped \u2014 cannot record. Omit explorationId for ad-hoc execution, or start a new session.`
7579
+ );
7580
+ }
7581
+ const device = args.device ?? session?.device;
7582
+ if (!device) {
7583
+ return this.fail("device required (or pass an active explorationId)");
7584
+ }
7585
+ if (willRecord) {
7586
+ const err = validateRecordingArgs(args);
7587
+ if (err) return this.fail(err);
7588
+ } else {
7589
+ const missing = missingRequiredFields(args);
7590
+ if (missing) return this.fail(missing);
7591
+ }
7592
+ const result = await runDriverAction(this.services.driver, device, args);
7593
+ if (willRecord) {
7594
+ const entry = this.services.exploration.addEntry(args.explorationId, {
7595
+ action: args.action,
7596
+ device,
7597
+ description: args.description,
7598
+ section: args.section,
7599
+ ...args.selector !== void 0 ? { selector: args.selector } : {},
7600
+ ...args.value !== void 0 ? { value: args.value } : {},
7601
+ ...args.key !== void 0 ? { key: args.key } : {},
7602
+ ...args.direction !== void 0 ? { direction: args.direction } : {},
7603
+ ...args.from !== void 0 ? { from: args.from } : {},
7604
+ ...args.url !== void 0 ? { url: args.url } : {},
7605
+ ...args.bundleId !== void 0 ? { bundleId: args.bundleId } : {},
7606
+ ...args.clean !== void 0 ? { clean: args.clean } : {},
7607
+ ...args.timeoutMs !== void 0 ? { timeoutMs: args.timeoutMs } : {},
7608
+ ...args.optional !== void 0 ? { optional: args.optional } : {},
7609
+ ...args.button !== void 0 ? { button: args.button } : {}
7610
+ });
7611
+ return this.okJson({
7612
+ entryId: entry.entryId,
7613
+ recorded: true,
7614
+ success: true,
7615
+ ...result
7616
+ });
7617
+ }
7618
+ return this.okJson({ recorded: false, success: true, ...result });
7619
+ })
7620
+ );
7621
+ }
7622
+ };
7623
+ var ExploreRecordTool = class extends BaseTool {
7624
+ static {
7625
+ __name(this, "ExploreRecordTool");
7626
+ }
7627
+ register(server) {
7628
+ server.registerTool(
7629
+ "explore_record",
7630
+ {
7631
+ title: "Record an action without executing it",
7632
+ description: "Appends an ActionEntry to the session log without invoking the driver. Use to manually add a step (assert, after-the-fact action). Required: explorationId, action, section, description, plus per-action fields (same as explore_step). Same recording-time rejects apply (e.g. wait_for { optional: true }).",
7633
+ inputSchema: stepShape
7634
+ },
7635
+ async (rawArgs) => this.tracked("explore_record", rawArgs, async () => {
7636
+ const args = rawArgs;
7637
+ if (!args.explorationId) return this.fail("explorationId required");
7638
+ const session = this.services.exploration.getSession(args.explorationId);
7639
+ if (!session) return this.fail(`exploration session not found: ${args.explorationId}`);
7640
+ if (session.status !== "active") {
7641
+ return this.fail(`exploration session ${args.explorationId} is stopped`);
7642
+ }
7643
+ const device = args.device ?? session.device;
7644
+ const err = validateRecordingArgs({ ...args, device });
7645
+ if (err) return this.fail(err);
7646
+ const entry = this.services.exploration.addEntry(args.explorationId, {
7647
+ action: args.action,
7648
+ device,
7649
+ description: args.description,
7650
+ section: args.section,
7651
+ ...args.selector !== void 0 ? { selector: args.selector } : {},
7652
+ ...args.value !== void 0 ? { value: args.value } : {},
7653
+ ...args.key !== void 0 ? { key: args.key } : {},
7654
+ ...args.direction !== void 0 ? { direction: args.direction } : {},
7655
+ ...args.from !== void 0 ? { from: args.from } : {},
7656
+ ...args.url !== void 0 ? { url: args.url } : {},
7657
+ ...args.bundleId !== void 0 ? { bundleId: args.bundleId } : {},
7658
+ ...args.clean !== void 0 ? { clean: args.clean } : {},
7659
+ ...args.timeoutMs !== void 0 ? { timeoutMs: args.timeoutMs } : {},
7660
+ ...args.optional !== void 0 ? { optional: args.optional } : {},
7661
+ ...args.button !== void 0 ? { button: args.button } : {}
7662
+ });
7663
+ return this.okJson({ entryId: entry.entryId, recorded: true });
7664
+ })
7665
+ );
7666
+ }
7667
+ };
7668
+ var ExploreRemoveStepTool = class extends BaseTool {
7669
+ static {
7670
+ __name(this, "ExploreRemoveStepTool");
7671
+ }
7672
+ register(server) {
7673
+ server.registerTool(
7674
+ "explore_remove_step",
7675
+ {
7676
+ title: "Remove an entry from the recording session log",
7677
+ description: "Deletes the entry by entryId. Returns { removed: true } or { removed: false } if not found.",
7678
+ inputSchema: {
7679
+ explorationId: z7.string().min(1),
7680
+ entryId: z7.string().min(1)
7681
+ }
7682
+ },
7683
+ async ({ explorationId, entryId }) => this.tracked("explore_remove_step", { explorationId, entryId }, async () => {
7684
+ const session = this.services.exploration.getSession(explorationId);
7685
+ if (!session) return this.fail(`exploration session not found: ${explorationId}`);
7686
+ const removed = this.services.exploration.removeEntry(explorationId, entryId);
7687
+ return this.okJson({ removed });
7688
+ })
7689
+ );
7690
+ }
7691
+ };
7692
+ var GenerateDslFromExplorationTool = class extends BaseTool {
7693
+ static {
7694
+ __name(this, "GenerateDslFromExplorationTool");
7695
+ }
7696
+ register(server) {
7697
+ server.registerTool(
7698
+ "generate_dsl_from_exploration",
7699
+ {
7700
+ title: "Render the session log as a DSL test",
7701
+ description: "Returns { draftDsl, warnings } from the current ActionLog. Pure function \u2014 does not mutate the log. Warnings: FRAGILE_LOCATOR (selector lacks stable identifier), NO_DSL_PRIMITIVE (entry skipped \u2014 selector shape has no DSL function), BUNDLE_ID_IGNORED (app_launch with explicit bundleId \u2014 DSL appLaunch reads it from APP_BUNDLE_ID env). Adjacent same-section entries collapse into one //@collapse \u2026 //@endcollapse block.",
7702
+ inputSchema: {
7703
+ explorationId: z7.string().min(1)
7704
+ }
7705
+ },
7706
+ async ({ explorationId }) => this.tracked("generate_dsl_from_exploration", { explorationId }, async () => {
7707
+ const session = this.services.exploration.getSession(explorationId);
7708
+ if (!session) return this.fail(`exploration session not found: ${explorationId}`);
7709
+ const log = this.services.exploration.getLog(explorationId);
7710
+ const result = this.services.dslView.generate(session, log);
7711
+ return this.okJson(result);
7712
+ })
7713
+ );
7714
+ }
7715
+ };
7716
+ var SaveExplorationAsTestTool = class extends BaseTool {
7717
+ static {
7718
+ __name(this, "SaveExplorationAsTestTool");
7719
+ }
7720
+ register(server) {
7721
+ server.registerTool(
7722
+ "save_exploration_as_test",
7723
+ {
7724
+ title: "Persist the generated DSL as a runnable scenario",
7725
+ description: "Renders the ActionLog as DSL and writes it to unotest/e2e/<scenarioName>.js. Returns { path, actionCount, warnings }. Policy: by default, NO_DSL_PRIMITIVE warnings block the save \u2014 pass force: true to write anyway with `// SKIPPED \u2026` comments inline. If the file already exists, pass overwrite: true to replace it.",
7726
+ inputSchema: {
7727
+ explorationId: z7.string().min(1),
7728
+ scenarioName: z7.string().min(1),
7729
+ overwrite: z7.boolean().optional(),
7730
+ force: z7.boolean().optional()
7731
+ }
7732
+ },
7733
+ async ({ explorationId, scenarioName, overwrite, force }) => this.tracked(
7734
+ "save_exploration_as_test",
7735
+ { explorationId, scenarioName, overwrite, force },
7736
+ async () => {
7737
+ const session = this.services.exploration.getSession(explorationId);
7738
+ if (!session) return this.fail(`exploration session not found: ${explorationId}`);
7739
+ const log = this.services.exploration.getLog(explorationId);
7740
+ const { draftDsl, warnings } = this.services.dslView.generate(session, log);
7741
+ const blockers = warnings.filter((w) => w.type === "NO_DSL_PRIMITIVE");
7742
+ if (blockers.length > 0 && !force) {
7743
+ return this.failJson({
7744
+ error: "no_dsl_primitive",
7745
+ message: `${blockers.length} entry/entries have selector shapes with no DSL primitive. Pass force: true to write with '// SKIPPED' comments, or edit those entries first via explore_remove_step + explore_record.`,
7746
+ blockers,
7747
+ warnings
7748
+ });
7749
+ }
7750
+ try {
7751
+ const path = await this.services.scenarioRepository.save(scenarioName, draftDsl, {
7752
+ ...overwrite !== void 0 ? { overwrite } : {}
7753
+ });
7754
+ return this.okJson({ path, actionCount: log.entries.length, warnings });
7755
+ } catch (e) {
7756
+ return this.fail(e.message);
7757
+ }
7758
+ }
7759
+ )
7760
+ );
7761
+ }
7762
+ };
7763
+
5927
7764
  // src/mcp/tools/index.ts
5928
7765
  function registerAllTools(server, services) {
5929
7766
  new DevicesListTool({ envConfig: services.envConfig, sessionRecorder: services.sessionRecorder }).register(server);
5930
7767
  new ScreenshotTool({ driver: services.driver, envConfig: services.envConfig, sessionRecorder: services.sessionRecorder }).register(server);
5931
7768
  new A11yTreeTool({ driver: services.driver, treeInspector: services.treeInspector, sessionRecorder: services.sessionRecorder }).register(server);
5932
- new TapTool({ driver: services.driver, envConfig: services.envConfig, sessionRecorder: services.sessionRecorder }).register(server);
5933
- new TypeTool({ driver: services.driver, envConfig: services.envConfig, sessionRecorder: services.sessionRecorder }).register(server);
5934
- new PressKeyTool({ driver: services.driver, envConfig: services.envConfig, sessionRecorder: services.sessionRecorder }).register(server);
5935
- new SwipeTool({ driver: services.driver, envConfig: services.envConfig, sessionRecorder: services.sessionRecorder }).register(server);
5936
- new OpenDeeplinkTool({ driver: services.driver, envConfig: services.envConfig, sessionRecorder: services.sessionRecorder }).register(server);
5937
- new AppLaunchTool({ driver: services.driver, envConfig: services.envConfig, sessionRecorder: services.sessionRecorder }).register(server);
5938
- new WaitForTool({ driver: services.driver, envConfig: services.envConfig, sessionRecorder: services.sessionRecorder }).register(server);
5939
7769
  new SessionResetTool({ sessionRecorder: services.sessionRecorder }).register(server);
7770
+ const exploreServices = {
7771
+ driver: services.driver,
7772
+ envConfig: services.envConfig,
7773
+ exploration: services.exploration,
7774
+ dslView: services.dslView,
7775
+ scenarioRepository: services.scenarioRepository,
7776
+ sessionRecorder: services.sessionRecorder
7777
+ };
7778
+ new ExploreStartTool(exploreServices).register(server);
7779
+ new ExploreStopTool(exploreServices).register(server);
7780
+ new ExploreStateTool(exploreServices).register(server);
7781
+ new ExploreStepTool(exploreServices).register(server);
7782
+ new ExploreRecordTool(exploreServices).register(server);
7783
+ new ExploreRemoveStepTool(exploreServices).register(server);
7784
+ new GenerateDslFromExplorationTool(exploreServices).register(server);
7785
+ new SaveExplorationAsTestTool(exploreServices).register(server);
5940
7786
  new ResolveSelectorTool({
5941
7787
  driver: services.driver,
5942
7788
  selectorResolver: services.selectorResolver,
5943
7789
  treeInspector: services.treeInspector,
5944
7790
  screenshotAnnotator: services.screenshotAnnotator,
5945
7791
  failureExplainer: services.failureExplainer,
5946
- logger: services.logger
7792
+ logger: services.logger,
7793
+ sessionRecorder: services.sessionRecorder
5947
7794
  }).register(server);
5948
7795
  const dbgServices = {
5949
7796
  testRuntimeManager: services.testRuntimeManager,
@@ -5960,7 +7807,8 @@ function registerAllTools(server, services) {
5960
7807
  failureExplainer: services.failureExplainer,
5961
7808
  db: services.db,
5962
7809
  api: services.api,
5963
- shell: services.shell
7810
+ shell: services.shell,
7811
+ sessionRecorder: services.sessionRecorder
5964
7812
  };
5965
7813
  new RunTestTool(dbgServices).register(server);
5966
7814
  new StepTool(dbgServices).register(server);
@@ -5968,6 +7816,11 @@ function registerAllTools(server, services) {
5968
7816
  new InspectRuntimeTool(dbgServices).register(server);
5969
7817
  new AbortRuntimeTool(dbgServices).register(server);
5970
7818
  new ListRuntimesTool(dbgServices).register(server);
7819
+ new AppInstallTool({
7820
+ envConfig: services.envConfig,
7821
+ logger: services.logger,
7822
+ sessionRecorder: services.sessionRecorder
7823
+ }).register(server);
5971
7824
  }
5972
7825
  __name(registerAllTools, "registerAllTools");
5973
7826
 
@@ -6012,15 +7865,16 @@ async function startMcpServer() {
6012
7865
  services.logger.info(
6013
7866
  `unotest-mobile MCP server up on stdio (session log: ${services.sessionRecorder.getPath()})`
6014
7867
  );
7868
+ await new Promise(() => {
7869
+ });
6015
7870
  }
6016
7871
  __name(startMcpServer, "startMcpServer");
6017
7872
  if (import.meta.url === `file://${process.argv[1]}`) {
6018
- startMcpServer().catch((e) => {
6019
- console.error("[mcp] fatal:", e);
6020
- process.exit(1);
6021
- });
7873
+ runMain(async () => {
7874
+ await startMcpServer();
7875
+ return 0;
7876
+ }, 1);
6022
7877
  }
6023
7878
  export {
6024
7879
  startMcpServer
6025
7880
  };
6026
- //# sourceMappingURL=server.js.map