@unotest/mobile 0.1.1 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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;
@@ -170,33 +170,65 @@ function ensureLoaded() {
170
170
  }
171
171
  __name(ensureLoaded, "ensureLoaded");
172
172
  var EnvSchema = z.object({
173
- APP_BUNDLE_ID: z.string().min(1),
174
- APP_URL_SCHEME: z.string().min(1),
175
- INVITE_DEEPLINK_PREFIX: z.string().min(1),
176
- API_BASE_URL: z.string().url(),
173
+ // APP_BUNDLE_ID — required at appLaunch / install time. We let the schema
174
+ // accept it as optional so commands that don't touch the app (`doctor`,
175
+ // `lint`) work without it. Use-site (WdaDriver) validates and errors with
176
+ // a clear message if missing.
177
+ APP_BUNDLE_ID: z.string().min(1).optional(),
178
+ // APP_URL_SCHEME — only consumed by the (currently unimplemented) Expo
179
+ // dev-client recovery flow. Optional; reserved for future use.
180
+ APP_URL_SCHEME: z.string().min(1).optional(),
181
+ // APP_PERMISSIONS — comma-separated `simctl privacy` services that
182
+ // `install --clean` (CLI + MCP) auto-grants before launch. Populated
183
+ // by `install --update-env` from detected NS*UsageDescription keys in
184
+ // the .app's Info.plist (P4 / S4). Optional — apps that don't request
185
+ // privacy services leave this unset.
186
+ // Example: APP_PERMISSIONS=location,motion
187
+ APP_PERMISSIONS: z.string().optional(),
188
+ // API_BASE_URL — only required if scenarios call `apiCall(...)`. Lazy:
189
+ // the ApiClient is constructed at first use, not at startup.
190
+ API_BASE_URL: z.string().url().optional(),
177
191
  // PROJECT_ROOT — optional default cwd for the `shell(...)` DSL primitive.
178
192
  // When unset, shell commands run from process.cwd(). Set to the absolute
179
193
  // path of the project-under-test when its CLI must be invoked from a
180
- // specific directory (e.g. monorepo root for `pnpm --filter ...`).
194
+ // specific directory (e.g. monorepo root).
181
195
  PROJECT_ROOT: z.string().optional(),
182
- // DATABASE_URL — connection string for native pg/mysql/sqlite client
183
- // (Stage 0 plugin DbClient). Format examples:
196
+ // DATABASE_URL — only required if scenarios call `dbQuery(...)` /
197
+ // `dbExec(...)`. Lazy: the DbClient is constructed at first use, not at
198
+ // startup. Format examples:
184
199
  // postgresql://user:pass@host:5432/dbname
185
200
  // mysql://user:pass@host:3306/dbname
186
201
  // sqlite:./e2e.db
187
202
  // sqlite::memory:
188
- DATABASE_URL: z.string().min(1),
189
- SIM_A_NAME: z.string().min(1),
190
- SIM_B_NAME: z.string().min(1),
203
+ DATABASE_URL: z.string().min(1).optional(),
204
+ // SIM_A_NAME / SIM_B_NAME — schema-optional so non-UI commands work
205
+ // without them. Pool-aware validation in loadEnv() below requires the
206
+ // names for slots actually present in SIM_POOL.
207
+ SIM_A_NAME: z.string().min(1).optional(),
208
+ SIM_B_NAME: z.string().min(1).optional(),
191
209
  SIM_POOL: z.string().default("A,B"),
192
- METRO_URL: z.string().url(),
210
+ // METRO_URL — only consumed by the (currently unimplemented) Expo
211
+ // dev-client recovery flow. Optional; reserved for future use.
212
+ METRO_URL: z.string().url().optional(),
193
213
  // Expo dev-client builds show a "Development Servers" launcher after a clean
194
- // launch (clearState wipes the remembered Metro URL). When true, the driver
195
- // auto-opens `exp+<APP_URL_SCHEME>://expo-development-client/?url=<METRO_URL>`
196
- // after `app_launch clean: true` to bypass the launcher.
214
+ // launch. When true, the driver auto-opens
215
+ // `exp+<APP_URL_SCHEME>://expo-development-client/?url=<METRO_URL>` after
216
+ // `app_launch clean: true` to bypass the launcher. (Flow not yet wired.)
197
217
  EXPO_DEV_CLIENT: z.string().optional().transform((v) => v === "true" || v === "1"),
198
- SESSION_LOG_PATH: z.string().default("sessions/current.jsonl"),
199
- ARTIFACTS_DIR: z.string().default("artifacts"),
218
+ SESSION_LOG_PATH: z.string().default("unotest/sessions/current.jsonl"),
219
+ // Explicit kill-switch for session recording. When "1"/"true", or when
220
+ // SESSION_LOG_PATH is empty, buildApp wires a NoopSessionRecorder. Used
221
+ // by evals harness and any consumer that wants the MCP server to make
222
+ // no on-disk session log.
223
+ SESSION_LOG_DISABLE: z.string().optional().transform((v) => v === "1" || v === "true"),
224
+ // When true, recorder writes the FULL tool result alongside the
225
+ // truncated preview. Off by default — snapshots can be megabytes.
226
+ SESSION_LOG_FULL: z.string().optional().transform((v) => v === "1" || v === "true"),
227
+ ARTIFACTS_DIR: z.string().default("unotest/artifacts"),
228
+ // Where ExplorationService persists per-session JSONL recording logs.
229
+ // Default: <ARTIFACTS_DIR>/explorations. Folded into the gitignored
230
+ // `unotest/artifacts/` tree by the init template.
231
+ EXPLORATIONS_DIR: z.string().optional(),
200
232
  // WDA per-slot port mapping (D-13 parallel multi-device). Stored as a
201
233
  // comma-separated `slot=port` list, e.g. "A=8100,B=8101". Each slot present
202
234
  // in SIM_POOL needs a port.
@@ -227,7 +259,7 @@ ${issues}`
227
259
  const value = process.env[key];
228
260
  if (!value) {
229
261
  throw new Error(
230
- `SIM_POOL lists slot "${slot}" but ${key} is not set in unotest/.env`
262
+ `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}".`
231
263
  );
232
264
  }
233
265
  simBySlot[slot] = value;
@@ -258,7 +290,8 @@ ${issues}`
258
290
  wdaPortBySlot,
259
291
  defaultActionWaitMs: raw.WDA_DEFAULT_ACTION_WAIT_MS,
260
292
  defaultWaitForTimeoutMs: raw.WDA_DEFAULT_WAITFOR_TIMEOUT_MS,
261
- pausedRuntimeTtlMs: raw.PAUSED_RUNTIME_TTL_MS
293
+ pausedRuntimeTtlMs: raw.PAUSED_RUNTIME_TTL_MS,
294
+ explorationsDir: raw.EXPLORATIONS_DIR ?? `${raw.ARTIFACTS_DIR}/explorations`
262
295
  };
263
296
  return cached;
264
297
  }
@@ -397,27 +430,84 @@ async function listSimulators() {
397
430
  return out;
398
431
  }
399
432
  __name(listSimulators, "listSimulators");
400
- async function resolveSimByName(name) {
433
+ function friendlyRuntime(runtime) {
434
+ const m = runtime.match(/SimRuntime\.([A-Za-z]+)-(\d+)-(\d+)$/);
435
+ if (!m) return runtime;
436
+ return `${m[1]} ${m[2]}.${m[3]}`;
437
+ }
438
+ __name(friendlyRuntime, "friendlyRuntime");
439
+ async function resolveSimByName(spec) {
440
+ const { name, runtimeHint } = parseSimSpec(spec);
401
441
  const all = await listSimulators();
402
- const matches = all.filter((s) => s.name === name);
442
+ let matches = all.filter((s) => s.name === name);
443
+ if (runtimeHint) {
444
+ matches = matches.filter((s) => friendlyRuntime(s.runtime).toLowerCase().includes(runtimeHint.toLowerCase()));
445
+ }
403
446
  if (matches.length === 0) {
404
- const available = all.map((s) => s.name).join(", ") || "(none)";
405
- throw new Error(`Sim "${name}" not found. Available: ${available}`);
447
+ const available = all.map((s) => `${s.name} @ ${friendlyRuntime(s.runtime)}`).join(", ") || "(none)";
448
+ throw new Error(
449
+ `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").`
450
+ );
406
451
  }
407
452
  const booted = matches.find((s) => s.state === "Booted");
408
453
  return booted ?? matches[0];
409
454
  }
410
455
  __name(resolveSimByName, "resolveSimByName");
456
+ function parseSimSpec(spec) {
457
+ const idx = spec.lastIndexOf("@");
458
+ if (idx === -1) return { name: spec.trim() };
459
+ return {
460
+ name: spec.slice(0, idx).trim(),
461
+ runtimeHint: spec.slice(idx + 1).trim()
462
+ };
463
+ }
464
+ __name(parseSimSpec, "parseSimSpec");
411
465
  async function bootSim(udid) {
412
466
  try {
413
467
  await exec2("xcrun", ["simctl", "boot", udid]);
468
+ if (process.env.SIMCTL_HEADLESS !== "1") await openSimulatorApp();
469
+ return;
414
470
  } catch (e) {
415
471
  const msg = e.stderr ?? String(e);
416
- if (msg.includes("Booted") || msg.includes("current state: Booted")) return;
417
- throw e;
472
+ if (msg.includes("Booted") || msg.includes("current state: Booted")) {
473
+ if (process.env.SIMCTL_HEADLESS !== "1") await openSimulatorApp();
474
+ return;
475
+ }
476
+ const state = await currentSimState(udid).catch(() => null);
477
+ if (state === "Booted") {
478
+ if (process.env.SIMCTL_HEADLESS !== "1") await openSimulatorApp();
479
+ return;
480
+ }
481
+ if (state && state !== "Shutdown") {
482
+ throw new Error(
483
+ `simctl boot ${udid} failed and sim is in transitional state "${state}". Wait a few seconds and retry, or force-shutdown: \`xcrun simctl shutdown ${udid}\`.
484
+ Original error: ${msg.trim().split("\n").slice(0, 4).join(" | ")}`
485
+ );
486
+ }
487
+ throw new Error(
488
+ `simctl boot ${udid} failed. Sim is "${state ?? "unknown"}".
489
+ Common fixes:
490
+ \u2022 Open Simulator.app, pick this device manually, ensure it boots.
491
+ \u2022 Erase: \`xcrun simctl erase ${udid}\` (wipes content & settings).
492
+ \u2022 Restart CoreSimulator: \`sudo killall -9 com.apple.CoreSimulator.CoreSimulatorService\`.
493
+ Original error: ${msg.trim().split("\n").slice(0, 4).join(" | ")}`
494
+ );
418
495
  }
419
496
  }
420
497
  __name(bootSim, "bootSim");
498
+ async function currentSimState(udid) {
499
+ const sims = await listSimulators();
500
+ const sim = sims.find((s) => s.udid === udid);
501
+ return sim ? sim.state : null;
502
+ }
503
+ __name(currentSimState, "currentSimState");
504
+ async function openSimulatorApp() {
505
+ try {
506
+ await exec2("open", ["-a", "Simulator"]);
507
+ } catch {
508
+ }
509
+ }
510
+ __name(openSimulatorApp, "openSimulatorApp");
421
511
  async function shutdownSim(udid) {
422
512
  try {
423
513
  await exec2("xcrun", ["simctl", "shutdown", udid]);
@@ -445,9 +535,37 @@ async function terminateApp(udid, bundleId) {
445
535
  }
446
536
  __name(terminateApp, "terminateApp");
447
537
  async function launchApp(udid, bundleId, args = []) {
448
- await exec2("xcrun", ["simctl", "launch", udid, bundleId, ...args]);
538
+ const { stdout } = await exec2("xcrun", ["simctl", "launch", udid, bundleId, ...args]);
539
+ const m = stdout.match(/:\s*(\d+)/);
540
+ const pid = m ? Number.parseInt(m[1], 10) : 0;
541
+ return { pid };
449
542
  }
450
543
  __name(launchApp, "launchApp");
544
+ async function isPidAlive(pid) {
545
+ if (pid <= 0) return false;
546
+ try {
547
+ process.kill(pid, 0);
548
+ return true;
549
+ } catch {
550
+ return false;
551
+ }
552
+ }
553
+ __name(isPidAlive, "isPidAlive");
554
+ async function assertLaunchedAndStable(udid, bundleId, pid, options = {}) {
555
+ const settleMs = options.settleMs ?? 1500;
556
+ await new Promise((r) => setTimeout(r, settleMs));
557
+ const alive = await isPidAlive(pid);
558
+ if (alive) return;
559
+ const procName = bundleId.split(".").pop() ?? bundleId;
560
+ throw new Error(
561
+ `App "${bundleId}" started (PID ${pid}) but exited within ${settleMs}ms \u2014 looks like a crash on launch.
562
+ See the crash reason:
563
+ xcrun simctl spawn ${udid} log show --predicate 'process == "${procName}" OR senderImagePath CONTAINS "${bundleId}"' --last 30s --info
564
+ Or open Console.app, filter by your app name.
565
+ Common causes: missing native module (rebuild after changing native deps), JS bundle baked against wrong workspace/env, signing issues.`
566
+ );
567
+ }
568
+ __name(assertLaunchedAndStable, "assertLaunchedAndStable");
451
569
  async function uninstallApp(udid, bundleId) {
452
570
  try {
453
571
  await exec2("xcrun", ["simctl", "uninstall", udid, bundleId]);
@@ -459,6 +577,63 @@ async function installApp(udid, appPath) {
459
577
  await exec2("xcrun", ["simctl", "install", udid, appPath]);
460
578
  }
461
579
  __name(installApp, "installApp");
580
+ async function isAppInstalled(udid, bundleId) {
581
+ try {
582
+ await exec2("xcrun", ["simctl", "get_app_container", udid, bundleId]);
583
+ return true;
584
+ } catch {
585
+ return false;
586
+ }
587
+ }
588
+ __name(isAppInstalled, "isAppInstalled");
589
+ async function eraseSim(udid) {
590
+ await exec2("xcrun", ["simctl", "erase", udid]);
591
+ }
592
+ __name(eraseSim, "eraseSim");
593
+ async function keychainResetSim(udid) {
594
+ await exec2("xcrun", ["simctl", "keychain", udid, "reset"]);
595
+ }
596
+ __name(keychainResetSim, "keychainResetSim");
597
+ async function privacyGrantSim(udid, service, bundleId) {
598
+ await exec2("xcrun", ["simctl", "privacy", udid, "grant", service, bundleId]);
599
+ }
600
+ __name(privacyGrantSim, "privacyGrantSim");
601
+ async function pinEnglishKeyboardSim(udid) {
602
+ await exec2("xcrun", [
603
+ "simctl",
604
+ "spawn",
605
+ udid,
606
+ "defaults",
607
+ "write",
608
+ "-g",
609
+ "AppleKeyboards",
610
+ "-array",
611
+ "en_US@hw=US;sw=QWERTY"
612
+ ]);
613
+ await exec2("xcrun", [
614
+ "simctl",
615
+ "spawn",
616
+ udid,
617
+ "defaults",
618
+ "write",
619
+ "-g",
620
+ "AppleLanguages",
621
+ "-array",
622
+ "en"
623
+ ]);
624
+ await exec2("xcrun", [
625
+ "simctl",
626
+ "spawn",
627
+ udid,
628
+ "defaults",
629
+ "write",
630
+ "-g",
631
+ "AppleLocale",
632
+ "-string",
633
+ "en_US"
634
+ ]);
635
+ }
636
+ __name(pinEnglishKeyboardSim, "pinEnglishKeyboardSim");
462
637
 
463
638
  // src/driver/simctl/adapter.ts
464
639
  var SimctlAdapter = class {
@@ -483,6 +658,9 @@ var SimctlAdapter = class {
483
658
  async launch(udid, bundleId, args) {
484
659
  return launchApp(udid, bundleId, args);
485
660
  }
661
+ async assertLaunchedAndStable(udid, bundleId, pid, settleMs) {
662
+ return assertLaunchedAndStable(udid, bundleId, pid, settleMs !== void 0 ? { settleMs } : {});
663
+ }
486
664
  async terminate(udid, bundleId) {
487
665
  return terminateApp(udid, bundleId);
488
666
  }
@@ -492,12 +670,51 @@ var SimctlAdapter = class {
492
670
  async screenshot(udid) {
493
671
  return screenshotPng(udid);
494
672
  }
673
+ async isInstalled(udid, bundleId) {
674
+ return isAppInstalled(udid, bundleId);
675
+ }
676
+ async erase(udid) {
677
+ return eraseSim(udid);
678
+ }
679
+ /** B5 — wipes simulator keychain so auth tokens don't survive `clean`
680
+ * launches. Used by `installApp({clean})` and `WdaDriver.appLaunch({clean})`. */
681
+ async keychainReset(udid) {
682
+ return keychainResetSim(udid);
683
+ }
684
+ /** S4 — pre-grant an iOS privacy service to a bundle so the app skips
685
+ * the SpringBoard permission dialog on first launch. */
686
+ async privacyGrant(udid, service, bundleId) {
687
+ return privacyGrantSim(udid, service, bundleId);
688
+ }
689
+ /** S8 — pin the sim's keyboard to en_US@QWERTY so WDA's typeText
690
+ * doesn't drop Latin chars when the sim was last on a Cyrillic layout. */
691
+ async pinEnglishKeyboard(udid) {
692
+ return pinEnglishKeyboardSim(udid);
693
+ }
694
+ async openSimulatorApp() {
695
+ return openSimulatorApp();
696
+ }
495
697
  };
496
698
 
497
- // src/driver/wda/session.ts
498
- import { setTimeout as delay } from "timers/promises";
499
-
500
699
  // src/driver/wda/http-client.ts
700
+ var NoAlertPresentError = class extends Error {
701
+ static {
702
+ __name(this, "NoAlertPresentError");
703
+ }
704
+ constructor(message) {
705
+ super(message);
706
+ this.name = "NoAlertPresentError";
707
+ }
708
+ };
709
+ var WdaSessionGoneError = class extends Error {
710
+ static {
711
+ __name(this, "WdaSessionGoneError");
712
+ }
713
+ constructor(message) {
714
+ super(message);
715
+ this.name = "WdaSessionGoneError";
716
+ }
717
+ };
501
718
  var WdaHttpClient = class {
502
719
  static {
503
720
  __name(this, "WdaHttpClient");
@@ -574,6 +791,39 @@ var WdaHttpClient = class {
574
791
  async openUrl(sessionId, url) {
575
792
  await this.post(`/session/${encodeURIComponent(sessionId)}/url`, { url });
576
793
  }
794
+ // ---- Alerts (native SpringBoard) ---------------------------------------
795
+ //
796
+ // These hit WDA's /alert/* endpoints which talk to the *system* alert
797
+ // hierarchy (SpringBoard process), NOT the app's a11y tree. Use for
798
+ // UIAlertController-backed dialogs: permission prompts, ATT, Sign Out
799
+ // confirms, iOS update banners. App-level Modals belong to regular tap.
800
+ //
801
+ // `name` in WdaAcceptAlertRequest taps a specific button by label.
802
+ // Omitted = WDA's position-based fallback, which is kind-dependent (see
803
+ // FBAlert.m): UIAlertController accept = LAST button, dismiss = FIRST;
804
+ // action sheet is reversed. Pass `name` whenever the alert isn't a plain
805
+ // 1-button modal — it's the only label-stable path.
806
+ async acceptAlert(sessionId, req = {}) {
807
+ await mapNoAlert(
808
+ this.post(
809
+ `/session/${encodeURIComponent(sessionId)}/alert/accept`,
810
+ req.name ? { name: req.name } : {}
811
+ )
812
+ );
813
+ }
814
+ async dismissAlert(sessionId) {
815
+ await mapNoAlert(this.post(`/session/${encodeURIComponent(sessionId)}/alert/dismiss`, {}));
816
+ }
817
+ async alertText(sessionId) {
818
+ return mapNoAlert(
819
+ this.get(`/session/${encodeURIComponent(sessionId)}/alert/text`)
820
+ );
821
+ }
822
+ async alertButtons(sessionId) {
823
+ return mapNoAlert(
824
+ this.get(`/session/${encodeURIComponent(sessionId)}/alert/buttons`)
825
+ );
826
+ }
577
827
  // ---- internals ---------------------------------------------------------
578
828
  async get(path) {
579
829
  return this.request("GET", path);
@@ -593,12 +843,32 @@ var WdaHttpClient = class {
593
843
  const resp = await this.fetchFn(`${this.baseUrl}${path}`, init);
594
844
  if (!resp.ok) {
595
845
  const text = await resp.text().catch(() => "<no body>");
596
- throw new Error(`WDA ${method} ${path} failed: HTTP ${resp.status} \u2014 ${text.slice(0, 500)}`);
846
+ const snippet = text.slice(0, 500);
847
+ if (resp.status === 404 && /no such session|could not find session|invalid session id/i.test(snippet)) {
848
+ throw new WdaSessionGoneError(
849
+ `WDA ${method} ${path} reports session is gone: ${snippet}`
850
+ );
851
+ }
852
+ throw new Error(`WDA ${method} ${path} failed: HTTP ${resp.status} \u2014 ${snippet}`);
597
853
  }
598
854
  if (resp.status === 204) return void 0;
599
855
  return await resp.json();
600
856
  }
601
857
  };
858
+ async function mapNoAlert(p) {
859
+ try {
860
+ return await p;
861
+ } catch (err) {
862
+ const msg = err instanceof Error ? err.message : String(err);
863
+ if (/HTTP 404/.test(msg) && /alert/i.test(msg)) {
864
+ throw new NoAlertPresentError(
865
+ `No active iOS alert on this device. WDA: ${msg.slice(msg.indexOf("HTTP"))}`
866
+ );
867
+ }
868
+ throw err;
869
+ }
870
+ }
871
+ __name(mapNoAlert, "mapNoAlert");
602
872
  function extractElementId(h) {
603
873
  const id = h["element-6066-11e4-a52e-4f735466cecf"] ?? h.ELEMENT;
604
874
  if (!id) {
@@ -608,6 +878,9 @@ function extractElementId(h) {
608
878
  }
609
879
  __name(extractElementId, "extractElementId");
610
880
 
881
+ // src/driver/wda/session.ts
882
+ import { setTimeout as delay } from "timers/promises";
883
+
611
884
  // src/driver/wda/probe.ts
612
885
  async function tryProbeWda(port, timeoutMs, fetchFn = (input, init) => fetch(input, init)) {
613
886
  const ctrl = new AbortController();
@@ -746,6 +1019,7 @@ var WdaSession = class {
746
1019
  });
747
1020
  this.sessionId = created.sessionId ?? created.value.sessionId;
748
1021
  this.deps.logger.debug(`wda session started: ${this.deps.slot} (${this.sessionId})`);
1022
+ await this.warmupTree();
749
1023
  } catch (e) {
750
1024
  if (this.runnerHandle) {
751
1025
  try {
@@ -780,6 +1054,45 @@ var WdaSession = class {
780
1054
  }
781
1055
  }
782
1056
  }
1057
+ /**
1058
+ * Warm up the accessibility tree after a fresh session by polling
1059
+ * `/source` until two consecutive readings agree on node count. iOS
1060
+ * XCTest's a11y discovery is asynchronous: the first `/source` after
1061
+ * createSession can return a partial tree (often only the application
1062
+ * root) for ~150-300ms while attributes are still being populated. A
1063
+ * caller's first `a11yTree()` would then see that partial state and
1064
+ * the agent acts on a half-built screen. Caps at 4 attempts so a
1065
+ * truly animating screen (rare at start time) doesn't hang the boot.
1066
+ */
1067
+ async warmupTree() {
1068
+ if (this.deps.skipWarmup) return;
1069
+ const delayMs = this.deps.warmupDelayMs ?? 150;
1070
+ let prevCount = -1;
1071
+ for (let i = 0; i < 4; i++) {
1072
+ let count = 0;
1073
+ try {
1074
+ const resp = await this.client.source(this.sessionId);
1075
+ count = countWdaSource(resp.value);
1076
+ } catch (e) {
1077
+ this.deps.logger.warn(
1078
+ `WdaSession[${this.deps.slot}] warmup /source attempt ${i + 1} failed: ${e.message}`
1079
+ );
1080
+ }
1081
+ if (i > 0 && count > 0 && count === prevCount) {
1082
+ this.deps.logger.debug(
1083
+ `WdaSession[${this.deps.slot}] warmup settled at attempt ${i + 1} (${count} nodes)`
1084
+ );
1085
+ return;
1086
+ }
1087
+ prevCount = count;
1088
+ if (i < 3 && delayMs > 0) {
1089
+ await new Promise((r) => setTimeout(r, delayMs));
1090
+ }
1091
+ }
1092
+ this.deps.logger.warn(
1093
+ `WdaSession[${this.deps.slot}] warmup did not settle in 4 attempts \u2014 proceeding with possibly-partial tree`
1094
+ );
1095
+ }
783
1096
  async waitUntilReady(timeoutMs) {
784
1097
  const deadline = Date.now() + timeoutMs;
785
1098
  let lastErr;
@@ -797,6 +1110,13 @@ var WdaSession = class {
797
1110
  );
798
1111
  }
799
1112
  };
1113
+ function countWdaSource(n) {
1114
+ if (!n || typeof n !== "object") return 0;
1115
+ let total = 1;
1116
+ for (const c of n.children ?? []) total += countWdaSource(c);
1117
+ return total;
1118
+ }
1119
+ __name(countWdaSource, "countWdaSource");
800
1120
  async function defaultStartWdaRunner(handle, port, binaryProvider, simctl, logger) {
801
1121
  const probe = await tryProbeWda(port, 1500);
802
1122
  if (probe?.ready) {
@@ -831,7 +1151,7 @@ function mapNode(raw) {
831
1151
  if (name) out.testId = name;
832
1152
  if (text) out.text = text;
833
1153
  if (labelOut) out.label = labelOut;
834
- if (raw.type) out.role = raw.type;
1154
+ if (raw.type) out.role = shortRole(raw.type);
835
1155
  const bounds = parseRect(raw.rect);
836
1156
  if (bounds) out.bounds = bounds;
837
1157
  const enabled = parseBool(raw.isEnabled);
@@ -841,6 +1161,11 @@ function mapNode(raw) {
841
1161
  return out;
842
1162
  }
843
1163
  __name(mapNode, "mapNode");
1164
+ function shortRole(t) {
1165
+ if (!t) return void 0;
1166
+ return t.replace(/^XCUIElementType/, "").toLowerCase();
1167
+ }
1168
+ __name(shortRole, "shortRole");
844
1169
  function nonEmpty(s) {
845
1170
  if (s == null) return void 0;
846
1171
  const trimmed = s.trim();
@@ -913,10 +1238,24 @@ var WdaDriver = class _WdaDriver {
913
1238
  async appLaunch(slot, opts) {
914
1239
  const h = await this.boot(slot);
915
1240
  const bundleId = opts.bundleId ?? this.deps.appBundleId;
1241
+ if (!bundleId) {
1242
+ throw new Error(
1243
+ `appLaunch needs a bundle id \u2014 set APP_BUNDLE_ID in unotest/.env or pass it explicitly.`
1244
+ );
1245
+ }
1246
+ const installed = await this.simctl.isInstalled(h.udid, bundleId);
1247
+ if (!installed) {
1248
+ throw new Error(
1249
+ `App "${bundleId}" is not installed on ${h.name} (slot ${slot}).
1250
+ Install it first: \`npx unotest-mobile install <path-to-.app>\` (or set APP_PATH in unotest/.env and run \`unotest-mobile install\`).`
1251
+ );
1252
+ }
916
1253
  if (opts.clean) {
917
1254
  await this.simctl.terminate(h.udid, bundleId);
1255
+ await this.simctl.keychainReset(h.udid);
918
1256
  }
919
- await this.simctl.launch(h.udid, bundleId);
1257
+ const { pid } = await this.simctl.launch(h.udid, bundleId);
1258
+ await this.simctl.assertLaunchedAndStable(h.udid, bundleId, pid);
920
1259
  }
921
1260
  async openDeeplink(slot, url) {
922
1261
  const h = await this.boot(slot);
@@ -924,53 +1263,59 @@ var WdaDriver = class _WdaDriver {
924
1263
  }
925
1264
  // ---- UiDriver ----------------------------------------------------------
926
1265
  async tap(slot, selector) {
927
- const session = await this.getSession(slot);
928
- const bounds = await this.resolveBounds(slot, selector);
929
- const { cx, cy } = center(bounds);
930
- await session.client.tap(session.getSessionId(), { x: cx, y: cy });
1266
+ await this.withFreshSession(slot, async (session) => {
1267
+ const bounds = await this.resolveBounds(slot, selector);
1268
+ const { cx, cy } = center(bounds);
1269
+ await session.client.tap(session.getSessionId(), { x: cx, y: cy });
1270
+ });
931
1271
  }
932
1272
  async type(slot, selector, text) {
933
- const session = await this.getSession(slot);
934
- const elementId = await this.resolveElementId(slot, selector);
935
- await session.client.setElementValue(session.getSessionId(), elementId, {
936
- value: Array.from(text)
1273
+ await this.withFreshSession(slot, async (session) => {
1274
+ const elementId = await this.resolveElementId(slot, selector);
1275
+ await session.client.setElementValue(session.getSessionId(), elementId, {
1276
+ value: Array.from(text)
1277
+ });
937
1278
  });
938
1279
  }
939
1280
  async swipe(slot, direction, from) {
940
- const session = await this.getSession(slot);
941
- const size = await session.client.windowSize(session.getSessionId());
942
- const w = size.value.width;
943
- const h = size.value.height;
944
- let fromX, fromY;
945
- if (from) {
946
- const b = await this.resolveBounds(slot, from);
947
- const c = center(b);
948
- fromX = c.cx;
949
- fromY = c.cy;
950
- } else {
951
- fromX = w / 2;
952
- fromY = h / 2;
953
- }
954
- const dist2 = Math.min(w, h) * 0.4;
955
- const toX = direction === "left" ? fromX - dist2 : direction === "right" ? fromX + dist2 : fromX;
956
- const toY = direction === "up" ? fromY - dist2 : direction === "down" ? fromY + dist2 : fromY;
957
- await session.client.drag(session.getSessionId(), { fromX, fromY, toX, toY, duration: 0.3 });
1281
+ await this.withFreshSession(slot, async (session) => {
1282
+ const size = await session.client.windowSize(session.getSessionId());
1283
+ const w = size.value.width;
1284
+ const h = size.value.height;
1285
+ let fromX, fromY;
1286
+ if (from) {
1287
+ const b = await this.resolveBounds(slot, from);
1288
+ const c = center(b);
1289
+ fromX = c.cx;
1290
+ fromY = c.cy;
1291
+ } else {
1292
+ fromX = w / 2;
1293
+ fromY = h / 2;
1294
+ }
1295
+ const dist2 = Math.min(w, h) * 0.4;
1296
+ const toX = direction === "left" ? fromX - dist2 : direction === "right" ? fromX + dist2 : fromX;
1297
+ const toY = direction === "up" ? fromY - dist2 : direction === "down" ? fromY + dist2 : fromY;
1298
+ await session.client.drag(session.getSessionId(), { fromX, fromY, toX, toY, duration: 0.3 });
1299
+ });
958
1300
  }
959
1301
  async pressKey(slot, key) {
960
1302
  const session = await this.getSession(slot);
961
- if (key === "home") {
962
- await session.client.pressKey(session.getSessionId(), "home");
963
- return;
964
- }
965
- if (key === "enter") {
966
- await session.client.typeText(session.getSessionId(), { value: ["\n"] });
967
- return;
968
- }
969
- if (key === "escape") {
970
- await session.client.typeText(session.getSessionId(), { value: ["\x1B"] });
971
- return;
1303
+ if (key !== "home" && key !== "enter" && key !== "escape") {
1304
+ throw new Error(
1305
+ "pressKey('back') is not supported on iOS - use swipe-back or app-specific control"
1306
+ );
972
1307
  }
973
- throw new Error(`pressKey('back') is not supported on iOS \u2014 use swipe-back or app-specific control`);
1308
+ await this.withFreshSession(slot, async (session2) => {
1309
+ if (key === "home") {
1310
+ await session2.client.pressKey(session2.getSessionId(), "home");
1311
+ return;
1312
+ }
1313
+ if (key === "enter") {
1314
+ await session2.client.typeText(session2.getSessionId(), { value: ["\n"] });
1315
+ return;
1316
+ }
1317
+ await session2.client.typeText(session2.getSessionId(), { value: [""] });
1318
+ });
974
1319
  }
975
1320
  async waitFor(slot, selector, opts = {}) {
976
1321
  const timeoutMs = opts.timeoutMs ?? 1e4;
@@ -986,13 +1331,64 @@ var WdaDriver = class _WdaDriver {
986
1331
  }
987
1332
  // ---- InspectionDriver --------------------------------------------------
988
1333
  async screenshot(slot) {
989
- const session = await this.getSession(slot);
990
- return session.client.screenshot(session.getSessionId());
1334
+ return this.withFreshSession(
1335
+ slot,
1336
+ (session) => session.client.screenshot(session.getSessionId())
1337
+ );
991
1338
  }
992
1339
  async a11yTree(slot, _opts = {}) {
993
- const session = await this.getSession(slot);
994
- const resp = await session.client.source(session.getSessionId());
995
- return parseWdaSource(resp.value);
1340
+ return this.withFreshSession(slot, async (session) => {
1341
+ const resp = await session.client.source(session.getSessionId());
1342
+ return parseWdaSource(resp.value);
1343
+ });
1344
+ }
1345
+ async windowSize(slot) {
1346
+ return this.withFreshSession(slot, async (session) => {
1347
+ const size = await session.client.windowSize(session.getSessionId());
1348
+ return { width: size.value.width, height: size.value.height };
1349
+ });
1350
+ }
1351
+ // ---- AlertController ---------------------------------------------------
1352
+ //
1353
+ // Native UIAlertController dialogs live in SpringBoard, outside the app's
1354
+ // a11y tree. Resolver-driven `tap()` cannot reach them: even when a
1355
+ // selector "matches" by ordinal, the actions API targets app-process
1356
+ // coordinates and the SpringBoard alert stays put. WDA's /alert/*
1357
+ // endpoints know to talk to the active system alert.
1358
+ async acceptAlert(slot, button) {
1359
+ await this.withFreshSession(slot, async (session) => {
1360
+ await session.client.acceptAlert(
1361
+ session.getSessionId(),
1362
+ button !== void 0 ? { name: button } : {}
1363
+ );
1364
+ });
1365
+ }
1366
+ async dismissAlert(slot) {
1367
+ await this.withFreshSession(
1368
+ slot,
1369
+ (session) => session.client.dismissAlert(session.getSessionId())
1370
+ );
1371
+ }
1372
+ async readAlert(slot) {
1373
+ return this.withFreshSession(slot, async (session) => {
1374
+ const resp = await session.client.alertText(session.getSessionId());
1375
+ return { text: resp.value };
1376
+ });
1377
+ }
1378
+ // `null` on no-alert keeps the A11yTreeTool probe branch-free; the
1379
+ // outline pipeline only renders the `alert:` section when readAlert
1380
+ // succeeded first, so a second NoAlertPresentError here would be a
1381
+ // SpringBoard race anyway — quieter to drop than throw.
1382
+ async readAlertButtons(slot) {
1383
+ return this.withFreshSession(slot, async (session) => {
1384
+ try {
1385
+ const resp = await session.client.alertButtons(session.getSessionId());
1386
+ return resp.value;
1387
+ } catch (e) {
1388
+ if (e instanceof NoAlertPresentError) return null;
1389
+ throw e;
1390
+ }
1391
+ });
996
1392
  }
997
1393
  // ---- ContextController stub (D-9) --------------------------------------
998
1394
  async listContexts(_slot) {
@@ -1007,6 +1403,42 @@ var WdaDriver = class _WdaDriver {
1007
1403
  return _WdaDriver.NATIVE_CONTEXT;
1008
1404
  }
1009
1405
  // ---- internals ---------------------------------------------------------
1406
+ /**
1407
+ * B6 — run `fn` against the cached session for `slot`. If WDA reports
1408
+ * the session is gone (sim erase / app crash / runner killed) we
1409
+ * silently drop the dead session, create a fresh one, and retry the
1410
+ * call ONCE. Persistent failures bubble up to the caller as usual.
1411
+ *
1412
+ * Why ONCE: a single retry covers the "stale handle in cache" case
1413
+ * (the common one — `--erase` between an MCP eval setup and the first
1414
+ * tool call, runner restart, etc). If a second attempt also dies, the
1415
+ * underlying WDA is genuinely broken and a retry loop would just
1416
+ * waste 10+ seconds per call before the harness times out.
1417
+ *
1418
+ * Pre-existing `getSession` flow stays unchanged for ad-hoc internal
1419
+ * calls (resolveBounds / resolveElementId) — those use the cached
1420
+ * session directly and ride the retry of their outer caller.
1421
+ */
1422
+ async withFreshSession(slot, fn) {
1423
+ const session = await this.getSession(slot);
1424
+ try {
1425
+ return await fn(session);
1426
+ } catch (e) {
1427
+ const isSessionGone = e instanceof WdaSessionGoneError || e instanceof Error && e.name === "WdaSessionGoneError";
1428
+ if (!isSessionGone) throw e;
1429
+ const msg = e instanceof Error ? e.message : String(e);
1430
+ this.deps.logger.child(`wda:${slot}`).warn(
1431
+ `WDA session ${session.getSessionId()} is gone (${msg.slice(0, 200)}); dropping cached session and retrying once.`
1432
+ );
1433
+ try {
1434
+ await session.stop();
1435
+ } catch {
1436
+ }
1437
+ this.sessions.delete(slot);
1438
+ const fresh = await this.getSession(slot);
1439
+ return fn(fresh);
1440
+ }
1441
+ }
1010
1442
  async getSession(slot) {
1011
1443
  const existing = this.sessions.get(slot);
1012
1444
  if (existing) return existing;
@@ -1017,6 +1449,23 @@ var WdaDriver = class _WdaDriver {
1017
1449
  `No WDA port configured for slot "${slot}". Set EnvConfig.wdaPortBySlot or add WDA_PORT_${slot} to .env.`
1018
1450
  );
1019
1451
  }
1452
+ if (!this.deps.sessionFactory && !this.deps.appBundleId) {
1453
+ throw new Error(
1454
+ `WDA session needs APP_BUNDLE_ID \u2014 set it in unotest/.env. WebDriverAgent attaches to a specific app via the bundle id.`
1455
+ );
1456
+ }
1457
+ if (!this.deps.sessionFactory && this.deps.appBundleId) {
1458
+ const installed = await this.simctl.isInstalled(handle.udid, this.deps.appBundleId);
1459
+ if (!installed) {
1460
+ throw new Error(
1461
+ `App "${this.deps.appBundleId}" is not installed on ${handle.name} (slot ${slot}).
1462
+ Install it first:
1463
+ npx unotest-mobile install <path-to-.app> # if you have the .app
1464
+ npx unotest-mobile install # if APP_PATH is set in unotest/.env
1465
+ In an MCP session, call the \`app_install\` tool \u2014 it will read APP_PATH or ask you for the path.`
1466
+ );
1467
+ }
1468
+ }
1020
1469
  const session = this.deps.sessionFactory ? this.deps.sessionFactory(slot, handle, port) : new WdaSession({
1021
1470
  slot,
1022
1471
  handle,
@@ -1486,59 +1935,74 @@ function dist(a, b) {
1486
1935
  __name(dist, "dist");
1487
1936
 
1488
1937
  // src/inspection/tree-inspector.ts
1489
- var INTERACTIVE_ROLE_HINTS = [
1938
+ var INTERACTIVE_ROLES = /* @__PURE__ */ new Set([
1490
1939
  "button",
1491
1940
  "textfield",
1492
- "securefield",
1493
- "secureinput",
1494
- "input",
1495
- "link",
1941
+ "securetextfield",
1496
1942
  "switch",
1943
+ "checkbox",
1944
+ "link",
1497
1945
  "slider",
1498
1946
  "picker",
1499
- "checkbox",
1500
1947
  "tab",
1501
1948
  "menuitem"
1502
- ];
1949
+ ]);
1503
1950
  function isInteractiveRole(role) {
1504
- if (!role) return false;
1505
- const r = role.toLowerCase();
1506
- return INTERACTIVE_ROLE_HINTS.some((hint) => r.includes(hint));
1951
+ return role !== void 0 && INTERACTIVE_ROLES.has(role);
1507
1952
  }
1508
1953
  __name(isInteractiveRole, "isInteractiveRole");
1509
1954
  function isNoise(n) {
1510
1955
  return !n.testId && !n.text && !n.label && !isInteractiveRole(n.role);
1511
1956
  }
1512
1957
  __name(isNoise, "isNoise");
1513
- function stripAttrs(n, children) {
1514
- const out = { children };
1958
+ function dedupeFields(n) {
1959
+ const out = { children: n.children };
1960
+ if (n.role) out.role = n.role;
1515
1961
  if (n.testId) out.testId = n.testId;
1516
1962
  if (n.text) out.text = n.text;
1517
1963
  if (n.label) out.label = n.label;
1518
- if (isInteractiveRole(n.role)) out.role = n.role;
1519
- return out;
1520
- }
1521
- __name(stripAttrs, "stripAttrs");
1522
- function compactSubtree(n) {
1523
- let totalSeen = 1;
1524
- const newChildren = [];
1525
- for (const c of n.children) {
1526
- const r = compactSubtree(c);
1527
- totalSeen += r.totalSeen;
1528
- newChildren.push(...r.kept);
1964
+ if (n.clipped) out.clipped = n.clipped;
1965
+ if (out.text && out.label && out.text === out.label) {
1966
+ delete out.label;
1529
1967
  }
1530
- if (isNoise(n)) {
1531
- return { totalSeen, kept: newChildren };
1968
+ const interactive = isInteractiveRole(out.role);
1969
+ if (out.testId && !interactive && (out.testId === out.text || out.testId === out.label)) {
1970
+ delete out.testId;
1532
1971
  }
1533
- return { totalSeen, kept: [stripAttrs(n, newChildren)] };
1972
+ return out;
1534
1973
  }
1535
- __name(compactSubtree, "compactSubtree");
1536
- function countNodes(n) {
1537
- let total = 1;
1538
- for (const c of n.children) total += countNodes(c);
1539
- return total;
1974
+ __name(dedupeFields, "dedupeFields");
1975
+ function classify(bounds, viewport) {
1976
+ if (!bounds) return "on-screen";
1977
+ const { x, y, width, height } = bounds;
1978
+ const yMax = y + height;
1979
+ const xMax = x + width;
1980
+ if (yMax <= 0) return "top";
1981
+ if (y >= viewport.height) return "bottom";
1982
+ if (xMax <= 0) return "left";
1983
+ if (x >= viewport.width) return "right";
1984
+ if (y < 0) return "clipped:top";
1985
+ if (yMax > viewport.height) return "clipped:bottom";
1986
+ if (x < 0) return "clipped:left";
1987
+ if (xMax > viewport.width) return "clipped:right";
1988
+ return "on-screen";
1989
+ }
1990
+ __name(classify, "classify");
1991
+ function clippedSide(c) {
1992
+ switch (c) {
1993
+ case "clipped:top":
1994
+ return "top";
1995
+ case "clipped:bottom":
1996
+ return "bottom";
1997
+ case "clipped:left":
1998
+ return "left";
1999
+ case "clipped:right":
2000
+ return "right";
2001
+ default:
2002
+ return void 0;
2003
+ }
1540
2004
  }
1541
- __name(countNodes, "countNodes");
2005
+ __name(clippedSide, "clippedSide");
1542
2006
  function collectTestIds(n, counts) {
1543
2007
  if (n.testId) counts.set(n.testId, (counts.get(n.testId) ?? 0) + 1);
1544
2008
  for (const c of n.children) collectTestIds(c, counts);
@@ -1553,45 +2017,102 @@ function applyIndex(n, counts, running) {
1553
2017
  for (const c of n.children) applyIndex(c, counts, running);
1554
2018
  }
1555
2019
  __name(applyIndex, "applyIndex");
1556
- function indexDuplicates(tree) {
2020
+ function indexOnScreenDuplicates(nodes) {
1557
2021
  const counts = /* @__PURE__ */ new Map();
1558
- collectTestIds(tree, counts);
1559
- applyIndex(tree, counts, /* @__PURE__ */ new Map());
1560
- return tree;
2022
+ for (const n of nodes) collectTestIds(n, counts);
2023
+ const running = /* @__PURE__ */ new Map();
2024
+ for (const n of nodes) applyIndex(n, counts, running);
2025
+ }
2026
+ __name(indexOnScreenDuplicates, "indexOnScreenDuplicates");
2027
+ function countSemanticNodes(nodes) {
2028
+ let total = 0;
2029
+ for (const n of nodes) {
2030
+ total += 1;
2031
+ total += countSemanticNodes(n.children);
2032
+ }
2033
+ return total;
2034
+ }
2035
+ __name(countSemanticNodes, "countSemanticNodes");
2036
+ function countOffScreen(off) {
2037
+ return off.top.length + off.bottom.length + off.left.length + off.right.length;
2038
+ }
2039
+ __name(countOffScreen, "countOffScreen");
2040
+ function buildSemanticTree(raw, viewport) {
2041
+ const onScreenRoots = [];
2042
+ const offScreen = { top: [], bottom: [], left: [], right: [] };
2043
+ let totalSeen = 0;
2044
+ function makeKept(n, clipped) {
2045
+ const base = { children: [] };
2046
+ if (n.role) base.role = n.role;
2047
+ if (n.testId) base.testId = n.testId;
2048
+ if (n.text) base.text = n.text;
2049
+ if (n.label) base.label = n.label;
2050
+ if (clipped) base.clipped = clipped;
2051
+ return dedupeFields(base);
2052
+ }
2053
+ __name(makeKept, "makeKept");
2054
+ function makeOffScreen(n) {
2055
+ const out = {};
2056
+ if (n.role) out.role = n.role;
2057
+ if (n.testId) out.testId = n.testId;
2058
+ if (n.text) out.text = n.text;
2059
+ if (n.label) out.label = n.label;
2060
+ const deduped = dedupeFields({ ...out, children: [] });
2061
+ const flat = {};
2062
+ if (deduped.role) flat.role = deduped.role;
2063
+ if (deduped.testId) flat.testId = deduped.testId;
2064
+ if (deduped.text) flat.text = deduped.text;
2065
+ if (deduped.label) flat.label = deduped.label;
2066
+ return flat;
2067
+ }
2068
+ __name(makeOffScreen, "makeOffScreen");
2069
+ function walk2(node, onScreenParent) {
2070
+ totalSeen += 1;
2071
+ const c = classify(node.bounds, viewport);
2072
+ const onSide = c === "on-screen" || c.startsWith("clipped:");
2073
+ if (onSide) {
2074
+ if (isNoise(node)) {
2075
+ for (const child of node.children) walk2(child, onScreenParent);
2076
+ return;
2077
+ }
2078
+ const kept = makeKept(node, clippedSide(c));
2079
+ if (onScreenParent) onScreenParent.children.push(kept);
2080
+ else onScreenRoots.push(kept);
2081
+ for (const child of node.children) walk2(child, kept);
2082
+ return;
2083
+ }
2084
+ const side = c;
2085
+ if (!isNoise(node)) {
2086
+ offScreen[side].push(makeOffScreen(node));
2087
+ }
2088
+ for (const child of node.children) walk2(child, null);
2089
+ }
2090
+ __name(walk2, "walk");
2091
+ walk2(raw, null);
2092
+ indexOnScreenDuplicates(onScreenRoots);
2093
+ return {
2094
+ on_screen: onScreenRoots,
2095
+ off_screen: offScreen,
2096
+ _meta: {
2097
+ totalNodes: totalSeen,
2098
+ onScreen: countSemanticNodes(onScreenRoots),
2099
+ offScreen: countOffScreen(offScreen),
2100
+ viewport: { width: viewport.width, height: viewport.height }
2101
+ }
2102
+ };
1561
2103
  }
1562
- __name(indexDuplicates, "indexDuplicates");
2104
+ __name(buildSemanticTree, "buildSemanticTree");
1563
2105
  var TreeInspector = class {
1564
2106
  static {
1565
2107
  __name(this, "TreeInspector");
1566
2108
  }
1567
2109
  /**
1568
- * D-16-aware compaction:
1569
- * - drop wrappers with no identifying signal,
1570
- * - strip layout-only attributes (bounds, enabled, focused, non-interactive role),
1571
- * - keep text-only and label-only leaves,
1572
- * - report `_meta` at the root.
1573
- */
1574
- compactTree(root) {
1575
- const result = compactSubtree(root);
1576
- const out = result.kept.length === 1 ? result.kept[0] : { children: result.kept };
1577
- const shown = countNodes(out);
1578
- out._meta = { totalNodes: result.totalSeen, shown, mode: "compact" };
1579
- return out;
1580
- }
1581
- /**
1582
- * Convenience: compact + index duplicates. The shape most MCP-tools want.
2110
+ * Partition a raw a11y tree against a viewport. The P1 entry — outline
2111
+ * renderer (in `outline-renderer.ts`) consumes the returned
2112
+ * `SemanticTree` directly.
1583
2113
  */
1584
- compactWithIds(root) {
1585
- return indexDuplicates(this.compactTree(root));
1586
- }
1587
- /**
1588
- * Annotate the raw tree as `full` (carries _meta for symmetry, no shape
1589
- * changes). Useful for MCP tools whose only choice is compact vs full.
1590
- */
1591
- withMode(root, mode) {
1592
- if (mode === "compact") return this.compactWithIds(root);
1593
- const total = countNodes(root);
1594
- return { ...root, _meta: { totalNodes: total, shown: total, mode: "full" } };
2114
+ semanticTree(raw, viewport) {
2115
+ return buildSemanticTree(raw, viewport);
1595
2116
  }
1596
2117
  /**
1597
2118
  * DFS path from root to `target`, encoded as "child-index/child-index/...".
@@ -2863,6 +3384,10 @@ var AstExecutor = class {
2863
3384
  }
2864
3385
  return;
2865
3386
  }
3387
+ if (stmt instanceof MetaBlockStatement) {
3388
+ yield* this.execStatement(stmt.body, runtime, opts, getSteps, setSteps, runStartMs, userFns, callStack);
3389
+ return;
3390
+ }
2866
3391
  if (stmt instanceof ReturnStatement) {
2867
3392
  const expr = stmt.expression;
2868
3393
  const value = yield* this.evalExpression(expr, runtime, opts, getSteps, setSteps, runStartMs, userFns, callStack);
@@ -3274,6 +3799,11 @@ var DslLinter = class {
3274
3799
  if (!this.deps.registry.has(name) && !userFns.has(name)) {
3275
3800
  diags.push(diag("error", "E1", `Unknown DSL function "${name}"`, call));
3276
3801
  }
3802
+ const fn = this.deps.registry.get(name);
3803
+ if (fn) {
3804
+ this.checkArity(call, fn, diags);
3805
+ this.checkArgTypes(call, fn, diags);
3806
+ }
3277
3807
  if (name === "setDevice" && call.arguments.length > 0) {
3278
3808
  const arg0 = call.arguments[0];
3279
3809
  if (arg0 instanceof ValueExpression) {
@@ -3309,6 +3839,77 @@ var DslLinter = class {
3309
3839
  this.checkExpression(arg, diags, recordCall, isFirstUiSeen, userFns, allowReturn);
3310
3840
  }
3311
3841
  }
3842
+ checkArity(call, fn, diags) {
3843
+ const max = fn.argTypes.length;
3844
+ const min = fn.minArgs ?? max;
3845
+ const got = call.arguments.length;
3846
+ if (got < min) {
3847
+ diags.push(
3848
+ diag(
3849
+ "error",
3850
+ "E5",
3851
+ `${fn.name}(): expected ${min === max ? `${min}` : `at least ${min}`} arg${min === 1 ? "" : "s"}, got ${got}`,
3852
+ call
3853
+ )
3854
+ );
3855
+ return;
3856
+ }
3857
+ if (!fn.variadic && got > max) {
3858
+ diags.push(
3859
+ diag(
3860
+ "error",
3861
+ "E5",
3862
+ `${fn.name}(): expected at most ${max} arg${max === 1 ? "" : "s"}, got ${got}`,
3863
+ call
3864
+ )
3865
+ );
3866
+ }
3867
+ }
3868
+ /**
3869
+ * For each provided arg up to argTypes.length, infer its static type and
3870
+ * compare to the declared signature. Only emits E6 when the inferred type
3871
+ * is UNAMBIGUOUS — we never flag "any" sources (variables, helper-call
3872
+ * results, BinaryExpression, ArrayExpression).
3873
+ */
3874
+ checkArgTypes(call, fn, diags) {
3875
+ const limit = Math.min(call.arguments.length, fn.argTypes.length);
3876
+ for (let i = 0; i < limit; i++) {
3877
+ const expected = fn.argTypes[i];
3878
+ if (expected === "any") continue;
3879
+ const inferred = this.inferExprType(call.arguments[i]);
3880
+ if (inferred === "any") continue;
3881
+ if (inferred === expected) continue;
3882
+ if (expected === "boolean" && inferred === "number" || expected === "number" && inferred === "boolean") {
3883
+ continue;
3884
+ }
3885
+ diags.push(
3886
+ diag(
3887
+ "error",
3888
+ "E6",
3889
+ `${fn.name}(): arg ${i} expected ${expected}, got ${describeInferred(call.arguments[i], inferred)}`,
3890
+ call
3891
+ )
3892
+ );
3893
+ }
3894
+ }
3895
+ /** Best-effort static type for an expression node. "any" = unknown. */
3896
+ inferExprType(expr) {
3897
+ if (expr instanceof ValueExpression) {
3898
+ const v = expr.value;
3899
+ if (v instanceof StringValue) return "string";
3900
+ if (v instanceof NumberValue) return "number";
3901
+ return "any";
3902
+ }
3903
+ if (expr instanceof VariableExpression) {
3904
+ if (expr.name === "true" || expr.name === "false") return "boolean";
3905
+ return "any";
3906
+ }
3907
+ if (expr instanceof FunctionalExpression) {
3908
+ const callee = this.deps.registry.get(expr.name);
3909
+ return callee ? callee.returnType : "any";
3910
+ }
3911
+ return "any";
3912
+ }
3312
3913
  isForbiddenStatement(stmt) {
3313
3914
  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;
3314
3915
  }
@@ -3320,6 +3921,19 @@ function isBlockMappable2(name) {
3320
3921
  return name.startsWith("test_") || name.startsWith("flow_");
3321
3922
  }
3322
3923
  __name(isBlockMappable2, "isBlockMappable");
3924
+ function describeInferred(expr, inferred) {
3925
+ if (expr instanceof FunctionalExpression) {
3926
+ return `${inferred} (${expr.name}() returns ${inferred})`;
3927
+ }
3928
+ if (expr instanceof ValueExpression) {
3929
+ return `${inferred} literal`;
3930
+ }
3931
+ if (expr instanceof VariableExpression && (expr.name === "true" || expr.name === "false")) {
3932
+ return `boolean literal \`${expr.name}\``;
3933
+ }
3934
+ return inferred;
3935
+ }
3936
+ __name(describeInferred, "describeInferred");
3323
3937
  function diag(severity, code, message, node) {
3324
3938
  const tok = node.token;
3325
3939
  return {
@@ -3355,6 +3969,41 @@ var FunctionRegistry = class {
3355
3969
  }
3356
3970
  };
3357
3971
 
3972
+ // src/dsl/functions/alerts.ts
3973
+ function asString(x, fn, idx) {
3974
+ if (typeof x !== "string") throw new Error(`${fn}(): arg ${idx} must be a string. Got ${typeof x}.`);
3975
+ return x;
3976
+ }
3977
+ __name(asString, "asString");
3978
+ var acceptAlert = {
3979
+ name: "acceptAlert",
3980
+ argTypes: ["string"],
3981
+ returnType: "void",
3982
+ minArgs: 0,
3983
+ invoke: /* @__PURE__ */ __name(async (runtime, buttonArg) => {
3984
+ const button = buttonArg !== void 0 ? asString(buttonArg, "acceptAlert", 0) : void 0;
3985
+ await runtime.driver.acceptAlert(runtime.currentDeviceSlot, button);
3986
+ }, "invoke")
3987
+ };
3988
+ var dismissAlert = {
3989
+ name: "dismissAlert",
3990
+ argTypes: [],
3991
+ returnType: "void",
3992
+ invoke: /* @__PURE__ */ __name(async (runtime) => {
3993
+ await runtime.driver.dismissAlert(runtime.currentDeviceSlot);
3994
+ }, "invoke")
3995
+ };
3996
+ var readAlert = {
3997
+ name: "readAlert",
3998
+ argTypes: [],
3999
+ returnType: "string",
4000
+ invoke: /* @__PURE__ */ __name(async (runtime) => {
4001
+ const { text } = await runtime.driver.readAlert(runtime.currentDeviceSlot);
4002
+ return text;
4003
+ }, "invoke")
4004
+ };
4005
+ var ALERT_FUNCTIONS = [acceptAlert, dismissAlert, readAlert];
4006
+
3358
4007
  // src/dsl/functions/asserts.ts
3359
4008
  function asSelector(x, fn, idx) {
3360
4009
  if (typeof x !== "object" || x === null) {
@@ -3470,11 +4119,11 @@ var ASSERT_FUNCTIONS = [
3470
4119
  ];
3471
4120
 
3472
4121
  // src/dsl/functions/data.ts
3473
- function asString(x, fn, idx) {
4122
+ function asString2(x, fn, idx) {
3474
4123
  if (typeof x !== "string") throw new Error(`${fn}(): arg ${idx} must be a string. Got ${typeof x}.`);
3475
4124
  return x;
3476
4125
  }
3477
- __name(asString, "asString");
4126
+ __name(asString2, "asString");
3478
4127
  function toSqlParam(v) {
3479
4128
  if (v === null || v === void 0) return null;
3480
4129
  if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") return v;
@@ -3485,8 +4134,9 @@ var dbQuery = {
3485
4134
  name: "dbQuery",
3486
4135
  argTypes: ["string"],
3487
4136
  returnType: "string",
4137
+ variadic: true,
3488
4138
  invoke: /* @__PURE__ */ __name(async (runtime, sqlArg, ...rest) => {
3489
- const sql = asString(sqlArg, "dbQuery", 0);
4139
+ const sql = asString2(sqlArg, "dbQuery", 0);
3490
4140
  const params = rest.map((a, i) => {
3491
4141
  try {
3492
4142
  return toSqlParam(a);
@@ -3507,8 +4157,9 @@ var dbExec = {
3507
4157
  name: "dbExec",
3508
4158
  argTypes: ["string"],
3509
4159
  returnType: "string",
4160
+ variadic: true,
3510
4161
  invoke: /* @__PURE__ */ __name(async (runtime, sqlArg, ...rest) => {
3511
- const sql = asString(sqlArg, "dbExec", 0);
4162
+ const sql = asString2(sqlArg, "dbExec", 0);
3512
4163
  const params = rest.map((a, i) => {
3513
4164
  try {
3514
4165
  return toSqlParam(a);
@@ -3530,8 +4181,9 @@ var shell = {
3530
4181
  name: "shell",
3531
4182
  argTypes: ["string"],
3532
4183
  returnType: "string",
4184
+ variadic: true,
3533
4185
  invoke: /* @__PURE__ */ __name(async (runtime, cmdArg, ...rest) => {
3534
- const cmd = asString(cmdArg, "shell", 0);
4186
+ const cmd = asString2(cmdArg, "shell", 0);
3535
4187
  const args = rest.map((a, i) => {
3536
4188
  if (a === null || a === void 0) return "";
3537
4189
  if (typeof a === "string") return a;
@@ -3549,14 +4201,14 @@ var apiCall = {
3549
4201
  returnType: "string",
3550
4202
  minArgs: 2,
3551
4203
  invoke: /* @__PURE__ */ __name(async (runtime, methodArg, pathArg, bodyArg) => {
3552
- const method = asString(methodArg, "apiCall", 0).toUpperCase();
3553
- const path = asString(pathArg, "apiCall", 1);
4204
+ const method = asString2(methodArg, "apiCall", 0).toUpperCase();
4205
+ const path = asString2(pathArg, "apiCall", 1);
3554
4206
  if (method !== "GET" && method !== "POST" && method !== "PUT" && method !== "PATCH" && method !== "DELETE") {
3555
4207
  throw new Error(`apiCall(): unsupported method "${method}"`);
3556
4208
  }
3557
4209
  let body;
3558
4210
  if (bodyArg !== void 0) {
3559
- const bodyJson = asString(bodyArg, "apiCall", 2);
4211
+ const bodyJson = asString2(bodyArg, "apiCall", 2);
3560
4212
  try {
3561
4213
  body = JSON.parse(bodyJson);
3562
4214
  } catch (e) {
@@ -3576,11 +4228,11 @@ var apiCall = {
3576
4228
  var DATA_FUNCTIONS = [dbQuery, dbExec, apiCall, shell];
3577
4229
 
3578
4230
  // src/dsl/functions/device.ts
3579
- function asString2(x, fn, idx) {
4231
+ function asString3(x, fn, idx) {
3580
4232
  if (typeof x !== "string") throw new Error(`${fn}(): arg ${idx} must be a string. Got ${typeof x}.`);
3581
4233
  return x;
3582
4234
  }
3583
- __name(asString2, "asString");
4235
+ __name(asString3, "asString");
3584
4236
  function asBoolean(x, fn, idx) {
3585
4237
  if (typeof x !== "boolean") throw new Error(`${fn}(): arg ${idx} must be a boolean. Got ${typeof x}.`);
3586
4238
  return x;
@@ -3591,7 +4243,7 @@ var setDevice = {
3591
4243
  argTypes: ["string"],
3592
4244
  returnType: "void",
3593
4245
  invoke: /* @__PURE__ */ __name((runtime, slotArg) => {
3594
- const slot = asString2(slotArg, "setDevice", 0);
4246
+ const slot = asString3(slotArg, "setDevice", 0);
3595
4247
  runtime.setCurrentDeviceSlot(slot);
3596
4248
  }, "invoke")
3597
4249
  };
@@ -3610,7 +4262,7 @@ var openDeeplink = {
3610
4262
  argTypes: ["string"],
3611
4263
  returnType: "void",
3612
4264
  invoke: /* @__PURE__ */ __name(async (runtime, urlArg) => {
3613
- const url = asString2(urlArg, "openDeeplink", 0);
4265
+ const url = asString3(urlArg, "openDeeplink", 0);
3614
4266
  await runtime.driver.openDeeplink(runtime.currentDeviceSlot, url);
3615
4267
  }, "invoke")
3616
4268
  };
@@ -3732,11 +4384,11 @@ function asSelector2(x, fn, idx) {
3732
4384
  return x;
3733
4385
  }
3734
4386
  __name(asSelector2, "asSelector");
3735
- function asString3(x, fn, idx) {
4387
+ function asString4(x, fn, idx) {
3736
4388
  if (typeof x !== "string") throw new Error(`${fn}(): arg ${idx} must be a string. Got ${typeof x}.`);
3737
4389
  return x;
3738
4390
  }
3739
- __name(asString3, "asString");
4391
+ __name(asString4, "asString");
3740
4392
  function asNumber2(x, fn, idx) {
3741
4393
  if (typeof x !== "number") throw new Error(`${fn}(): arg ${idx} must be a number. Got ${typeof x}.`);
3742
4394
  return x;
@@ -3775,7 +4427,7 @@ var type_ = {
3775
4427
  returnType: "void",
3776
4428
  invoke: /* @__PURE__ */ __name(async (runtime, selectorArg, textArg) => {
3777
4429
  const selector = asSelector2(selectorArg, "type", 0);
3778
- const text = asString3(textArg, "type", 1);
4430
+ const text = asString4(textArg, "type", 1);
3779
4431
  await pollUntilFound(runtime, selector, runtime.envConfig.defaultActionWaitMs);
3780
4432
  await runtime.driver.type(runtime.currentDeviceSlot, selector, text);
3781
4433
  }, "invoke")
@@ -3786,7 +4438,7 @@ var swipe = {
3786
4438
  returnType: "void",
3787
4439
  minArgs: 1,
3788
4440
  invoke: /* @__PURE__ */ __name(async (runtime, directionArg, fromArg) => {
3789
- const direction = asString3(directionArg, "swipe", 0);
4441
+ const direction = asString4(directionArg, "swipe", 0);
3790
4442
  if (direction !== "up" && direction !== "down" && direction !== "left" && direction !== "right") {
3791
4443
  throw new Error(`swipe(): direction must be 'up'|'down'|'left'|'right'. Got "${direction}".`);
3792
4444
  }
@@ -3803,7 +4455,7 @@ var pressKey = {
3803
4455
  argTypes: ["string"],
3804
4456
  returnType: "void",
3805
4457
  invoke: /* @__PURE__ */ __name(async (runtime, keyArg) => {
3806
- const key = asString3(keyArg, "pressKey", 0);
4458
+ const key = asString4(keyArg, "pressKey", 0);
3807
4459
  if (key !== "back" && key !== "home" && key !== "enter" && key !== "escape") {
3808
4460
  throw new Error(`pressKey(): key must be 'back'|'home'|'enter'|'escape'. Got "${key}".`);
3809
4461
  }
@@ -3837,6 +4489,7 @@ var ALL_DSL_FUNCTIONS = [
3837
4489
  ...DEVICE_FUNCTIONS,
3838
4490
  ...UI_FUNCTIONS,
3839
4491
  ...SELECTOR_FUNCTIONS,
4492
+ ...ALERT_FUNCTIONS,
3840
4493
  ...DATA_FUNCTIONS,
3841
4494
  ...ASSERT_FUNCTIONS,
3842
4495
  ...TIME_FUNCTIONS
@@ -4017,9 +4670,9 @@ var TestRuntimeManager = class {
4017
4670
  };
4018
4671
 
4019
4672
  // src/runner/fs-scenario-repository.ts
4020
- import { readFileSync as readFileSync2 } from "fs";
4673
+ import { readFileSync as readFileSync2, existsSync as existsSync2, mkdirSync as mkdirSync2, writeFileSync } from "fs";
4021
4674
  import { readdir } from "fs/promises";
4022
- import { resolve, join as join2, sep } from "path";
4675
+ import { dirname as dirname2, resolve, join as join2, sep } from "path";
4023
4676
  var FileSystemScenarioRepository = class {
4024
4677
  static {
4025
4678
  __name(this, "FileSystemScenarioRepository");
@@ -4047,13 +4700,28 @@ var FileSystemScenarioRepository = class {
4047
4700
  }
4048
4701
  }
4049
4702
  async load(name) {
4703
+ const path = this.pathFor(name);
4704
+ const source = readFileSync2(path, "utf8");
4705
+ return { name, source };
4706
+ }
4707
+ async has(name) {
4708
+ return existsSync2(this.pathFor(name));
4709
+ }
4710
+ async save(name, source, opts) {
4711
+ const path = this.pathFor(name);
4712
+ if (existsSync2(path) && !opts?.overwrite) {
4713
+ throw new Error(`scenario "${name}" already exists at ${path}`);
4714
+ }
4715
+ mkdirSync2(dirname2(path), { recursive: true });
4716
+ writeFileSync(path, source);
4717
+ return path;
4718
+ }
4719
+ pathFor(name) {
4050
4720
  if (name.includes("..") || name.startsWith("/") || name.includes("\\")) {
4051
4721
  throw new Error(`Invalid scenario name "${name}"`);
4052
4722
  }
4053
4723
  const relPath = name.split("/").join(sep) + ".js";
4054
- const path = join2(this.scenariosDir, relPath);
4055
- const source = readFileSync2(path, "utf8");
4056
- return { name, source };
4724
+ return join2(this.scenariosDir, relPath);
4057
4725
  }
4058
4726
  };
4059
4727
 
@@ -4882,7 +5550,7 @@ __name(collectOrigins, "collectOrigins");
4882
5550
 
4883
5551
  // src/runner/helper-repository.ts
4884
5552
  import { readdir as readdir2, readFile } from "fs/promises";
4885
- import { existsSync as existsSync2 } from "fs";
5553
+ import { existsSync as existsSync3 } from "fs";
4886
5554
  import { join as join3, relative } from "path";
4887
5555
  var FileSystemHelperRepository = class {
4888
5556
  static {
@@ -4895,7 +5563,7 @@ var FileSystemHelperRepository = class {
4895
5563
  this.helpersDir = join3(deps.scenariosDir, "_helpers");
4896
5564
  }
4897
5565
  async loadAll() {
4898
- if (!existsSync2(this.helpersDir)) return [];
5566
+ if (!existsSync3(this.helpersDir)) return [];
4899
5567
  const files = await collectJsFiles(this.helpersDir);
4900
5568
  const out = [];
4901
5569
  for (const absolutePath of files) {
@@ -4928,28 +5596,373 @@ async function collectJsFiles(dir) {
4928
5596
  __name(collectJsFiles, "collectJsFiles");
4929
5597
 
4930
5598
  // src/mcp/session-recorder.ts
4931
- import { appendFileSync, mkdirSync as mkdirSync2, writeFileSync } from "fs";
4932
- import { dirname as dirname2, resolve as resolve2 } from "path";
5599
+ import { appendFileSync, mkdirSync as mkdirSync3, writeFileSync as writeFileSync2 } from "fs";
5600
+ import { dirname as dirname3, resolve as resolve2 } from "path";
4933
5601
  var SessionRecorder = class {
4934
5602
  static {
4935
5603
  __name(this, "SessionRecorder");
4936
5604
  }
4937
5605
  path;
4938
- constructor(path) {
5606
+ full;
5607
+ constructor(path, opts = {}) {
4939
5608
  this.path = resolve2(process.cwd(), path);
4940
- mkdirSync2(dirname2(this.path), { recursive: true });
5609
+ this.full = opts.full ?? false;
5610
+ mkdirSync3(dirname3(this.path), { recursive: true });
4941
5611
  }
4942
5612
  record(action) {
4943
- const entry = { ts: (/* @__PURE__ */ new Date()).toISOString(), ...action };
5613
+ const { full_result, ...rest } = action;
5614
+ const entry = { ts: (/* @__PURE__ */ new Date()).toISOString(), ...rest };
5615
+ if (this.full && full_result !== void 0) entry.full_result = full_result;
4944
5616
  appendFileSync(this.path, JSON.stringify(entry) + "\n");
4945
5617
  }
4946
5618
  reset() {
4947
- writeFileSync(this.path, "");
5619
+ writeFileSync2(this.path, "");
4948
5620
  }
4949
5621
  getPath() {
4950
5622
  return this.path;
4951
5623
  }
4952
5624
  };
5625
+ var NoopSessionRecorder = class {
5626
+ static {
5627
+ __name(this, "NoopSessionRecorder");
5628
+ }
5629
+ record(_action) {
5630
+ }
5631
+ reset() {
5632
+ }
5633
+ getPath() {
5634
+ return "(disabled)";
5635
+ }
5636
+ };
5637
+
5638
+ // src/mcp/exploration/exploration.service.ts
5639
+ import { mkdirSync as mkdirSync4, readdirSync as readdirSync2, readFileSync as readFileSync3, appendFileSync as appendFileSync2, statSync } from "fs";
5640
+ import { join as join4 } from "path";
5641
+ import { randomUUID } from "crypto";
5642
+
5643
+ // src/mcp/exploration/stability.ts
5644
+ function classifyStability(selector) {
5645
+ if (!selector) return "stable";
5646
+ if (selector.testId) return "stable";
5647
+ if (selector.label && selector.ordinal === void 0) return "stable";
5648
+ if (selector.text && selector.ordinal === void 0) return "stable";
5649
+ return "fragile";
5650
+ }
5651
+ __name(classifyStability, "classifyStability");
5652
+
5653
+ // src/mcp/exploration/exploration.service.ts
5654
+ var ExplorationService = class {
5655
+ static {
5656
+ __name(this, "ExplorationService");
5657
+ }
5658
+ sessions = /* @__PURE__ */ new Map();
5659
+ logs = /* @__PURE__ */ new Map();
5660
+ dir;
5661
+ clock;
5662
+ generateId;
5663
+ constructor(deps) {
5664
+ this.dir = deps.explorationsDir;
5665
+ this.clock = deps.clock ?? (() => /* @__PURE__ */ new Date());
5666
+ this.generateId = deps.generateId ?? (() => randomUUID());
5667
+ mkdirSync4(this.dir, { recursive: true });
5668
+ this.restore();
5669
+ }
5670
+ startSession(params) {
5671
+ const explorationId = `exp-${this.generateId()}`;
5672
+ const actionLogId = `log-${this.generateId()}`;
5673
+ const startedAt = this.clock().toISOString();
5674
+ const session = {
5675
+ explorationId,
5676
+ scenarioName: params.scenarioName,
5677
+ device: params.device,
5678
+ status: "active",
5679
+ startedAt,
5680
+ actionLogId,
5681
+ ...params.title !== void 0 ? { title: params.title } : {},
5682
+ ...params.description !== void 0 ? { description: params.description } : {}
5683
+ };
5684
+ this.sessions.set(explorationId, session);
5685
+ this.logs.set(actionLogId, { logId: actionLogId, createdAt: startedAt, entries: [] });
5686
+ this.append(explorationId, { kind: "session_started", session });
5687
+ return session;
5688
+ }
5689
+ getSession(explorationId) {
5690
+ return this.sessions.get(explorationId);
5691
+ }
5692
+ getLog(explorationId) {
5693
+ const s = this.sessions.get(explorationId);
5694
+ return s ? this.logs.get(s.actionLogId) : void 0;
5695
+ }
5696
+ addEntry(explorationId, params) {
5697
+ const session = this.requireActive(explorationId);
5698
+ const log = this.logs.get(session.actionLogId);
5699
+ const entry = {
5700
+ entryId: `e-${this.generateId()}`,
5701
+ performedAt: this.clock().toISOString(),
5702
+ action: params.action,
5703
+ device: params.device,
5704
+ description: params.description,
5705
+ section: params.section,
5706
+ stability: classifyStability(params.selector),
5707
+ ...params.selector !== void 0 ? { selector: params.selector } : {},
5708
+ ...params.value !== void 0 ? { value: params.value } : {},
5709
+ ...params.key !== void 0 ? { key: params.key } : {},
5710
+ ...params.direction !== void 0 ? { direction: params.direction } : {},
5711
+ ...params.from !== void 0 ? { from: params.from } : {},
5712
+ ...params.url !== void 0 ? { url: params.url } : {},
5713
+ ...params.bundleId !== void 0 ? { bundleId: params.bundleId } : {},
5714
+ ...params.clean !== void 0 ? { clean: params.clean } : {},
5715
+ ...params.timeoutMs !== void 0 ? { timeoutMs: params.timeoutMs } : {},
5716
+ ...params.optional !== void 0 ? { optional: params.optional } : {},
5717
+ ...params.button !== void 0 ? { button: params.button } : {}
5718
+ };
5719
+ log.entries.push(entry);
5720
+ this.append(explorationId, { kind: "entry_added", entry });
5721
+ return entry;
5722
+ }
5723
+ removeEntry(explorationId, entryId) {
5724
+ const session = this.requireKnown(explorationId);
5725
+ const log = this.logs.get(session.actionLogId);
5726
+ const idx = log.entries.findIndex((e) => e.entryId === entryId);
5727
+ if (idx < 0) return false;
5728
+ log.entries.splice(idx, 1);
5729
+ this.append(explorationId, { kind: "entry_removed", entryId });
5730
+ return true;
5731
+ }
5732
+ stopSession(explorationId) {
5733
+ const session = this.requireKnown(explorationId);
5734
+ if (session.status === "stopped") return session;
5735
+ session.status = "stopped";
5736
+ this.append(explorationId, { kind: "session_stopped", stoppedAt: this.clock().toISOString() });
5737
+ return session;
5738
+ }
5739
+ listSessions() {
5740
+ return [...this.sessions.values()];
5741
+ }
5742
+ // ──────────────────────────────────────────────────────────────────────
5743
+ requireKnown(explorationId) {
5744
+ const s = this.sessions.get(explorationId);
5745
+ if (!s) throw new ExplorationNotFoundError(explorationId);
5746
+ return s;
5747
+ }
5748
+ requireActive(explorationId) {
5749
+ const s = this.requireKnown(explorationId);
5750
+ if (s.status !== "active") {
5751
+ throw new ExplorationStoppedError(explorationId);
5752
+ }
5753
+ return s;
5754
+ }
5755
+ append(explorationId, record) {
5756
+ appendFileSync2(join4(this.dir, `${explorationId}.jsonl`), JSON.stringify(record) + "\n");
5757
+ }
5758
+ restore() {
5759
+ let files;
5760
+ try {
5761
+ files = readdirSync2(this.dir).filter((f) => f.endsWith(".jsonl"));
5762
+ } catch {
5763
+ return;
5764
+ }
5765
+ for (const file of files) {
5766
+ const full = join4(this.dir, file);
5767
+ try {
5768
+ if (!statSync(full).isFile()) continue;
5769
+ const text = readFileSync3(full, "utf8");
5770
+ this.replayFile(text);
5771
+ } catch {
5772
+ }
5773
+ }
5774
+ }
5775
+ replayFile(jsonl) {
5776
+ const lines = jsonl.split("\n").filter((l) => l.length > 0);
5777
+ let session = null;
5778
+ let log = null;
5779
+ for (const line of lines) {
5780
+ let rec;
5781
+ try {
5782
+ rec = JSON.parse(line);
5783
+ } catch {
5784
+ continue;
5785
+ }
5786
+ switch (rec.kind) {
5787
+ case "session_started": {
5788
+ session = { ...rec.session };
5789
+ log = { logId: session.actionLogId, createdAt: session.startedAt, entries: [] };
5790
+ this.sessions.set(session.explorationId, session);
5791
+ this.logs.set(session.actionLogId, log);
5792
+ break;
5793
+ }
5794
+ case "entry_added": {
5795
+ if (log) log.entries.push(rec.entry);
5796
+ break;
5797
+ }
5798
+ case "entry_removed": {
5799
+ if (log) {
5800
+ const idx = log.entries.findIndex((e) => e.entryId === rec.entryId);
5801
+ if (idx >= 0) log.entries.splice(idx, 1);
5802
+ }
5803
+ break;
5804
+ }
5805
+ case "session_stopped": {
5806
+ if (session) session.status = "stopped";
5807
+ break;
5808
+ }
5809
+ }
5810
+ }
5811
+ }
5812
+ };
5813
+ var ExplorationNotFoundError = class extends Error {
5814
+ static {
5815
+ __name(this, "ExplorationNotFoundError");
5816
+ }
5817
+ code = "EXPLORATION_NOT_FOUND";
5818
+ constructor(explorationId) {
5819
+ super(`exploration session not found: ${explorationId}`);
5820
+ this.name = "ExplorationNotFoundError";
5821
+ }
5822
+ };
5823
+ var ExplorationStoppedError = class extends Error {
5824
+ static {
5825
+ __name(this, "ExplorationStoppedError");
5826
+ }
5827
+ code = "EXPLORATION_STOPPED";
5828
+ constructor(explorationId) {
5829
+ super(`exploration session already stopped: ${explorationId}`);
5830
+ this.name = "ExplorationStoppedError";
5831
+ }
5832
+ };
5833
+
5834
+ // src/mcp/exploration/format-selector.ts
5835
+ function formatSelector(selector) {
5836
+ if (selector.ordinal !== void 0) {
5837
+ const { ordinal: ordinal2, ...rest } = selector;
5838
+ const inner = formatSelector(rest);
5839
+ return inner === null ? null : `ordinal(${inner}, ${ordinal2})`;
5840
+ }
5841
+ if (selector.testId !== void 0) return `getByTestId(${JSON.stringify(selector.testId)})`;
5842
+ if (selector.text !== void 0) return `getByText(${JSON.stringify(selector.text)})`;
5843
+ if (selector.label !== void 0) return `getByLabel(${JSON.stringify(selector.label)})`;
5844
+ return null;
5845
+ }
5846
+ __name(formatSelector, "formatSelector");
5847
+ function describeShape(selector) {
5848
+ const keys = Object.keys(selector).filter((k) => selector[k] !== void 0);
5849
+ return keys.length === 0 ? "<empty>" : keys.sort().join("+");
5850
+ }
5851
+ __name(describeShape, "describeShape");
5852
+
5853
+ // src/mcp/exploration/dsl-view.service.ts
5854
+ var DslViewService = class {
5855
+ static {
5856
+ __name(this, "DslViewService");
5857
+ }
5858
+ generate(session, log) {
5859
+ const warnings = [];
5860
+ const lines = [];
5861
+ lines.push(renderHeader(session));
5862
+ lines.push(`function test_${session.scenarioName.replace(/[^a-zA-Z0-9_]/g, "_")}() {`);
5863
+ lines.push(` setDevice(${JSON.stringify(session.device)});`);
5864
+ let openSection = null;
5865
+ for (const entry of log.entries) {
5866
+ if (entry.section !== openSection) {
5867
+ if (openSection !== null) lines.push(" //@endcollapse");
5868
+ lines.push(` //@collapse(${JSON.stringify(entry.section)})`);
5869
+ openSection = entry.section;
5870
+ }
5871
+ const result = renderEntry(entry);
5872
+ if (result.kind === "skip") {
5873
+ warnings.push({
5874
+ entryId: entry.entryId,
5875
+ type: "NO_DSL_PRIMITIVE",
5876
+ message: result.reason
5877
+ });
5878
+ lines.push(` // SKIPPED (${result.reason}) \u2014 replace with getByTestId/getByText/getByLabel`);
5879
+ continue;
5880
+ }
5881
+ if (entry.stability === "fragile") {
5882
+ warnings.push({
5883
+ entryId: entry.entryId,
5884
+ type: "FRAGILE_LOCATOR",
5885
+ message: `${entry.action} selector lacks a stable identifier \u2014 add a testId in the app`
5886
+ });
5887
+ }
5888
+ for (const w of result.warnings ?? []) {
5889
+ warnings.push({ ...w, entryId: entry.entryId });
5890
+ }
5891
+ lines.push(" " + result.stmt);
5892
+ }
5893
+ if (openSection !== null) lines.push(" //@endcollapse");
5894
+ lines.push("}");
5895
+ return { draftDsl: lines.join("\n") + "\n", warnings };
5896
+ }
5897
+ };
5898
+ function renderHeader(session) {
5899
+ const title = session.title ?? session.scenarioName;
5900
+ return [
5901
+ `// id-${session.scenarioName}`,
5902
+ `// ${title}`,
5903
+ `// #4287f5`
5904
+ ].join("\n");
5905
+ }
5906
+ __name(renderHeader, "renderHeader");
5907
+ function renderEntry(entry) {
5908
+ const sel = /* @__PURE__ */ __name((s) => {
5909
+ const out = formatSelector(s);
5910
+ if (out === null) {
5911
+ return { kind: "skip", reason: `selector shape ${describeShape(s)} has no DSL primitive` };
5912
+ }
5913
+ return out;
5914
+ }, "sel");
5915
+ switch (entry.action) {
5916
+ case "tap": {
5917
+ if (!entry.selector) return { kind: "skip", reason: "tap missing selector" };
5918
+ const s = sel(entry.selector);
5919
+ if (typeof s !== "string") return s;
5920
+ return { kind: "stmt", stmt: `tap(${s});` };
5921
+ }
5922
+ case "type": {
5923
+ if (!entry.selector) return { kind: "skip", reason: "type missing selector" };
5924
+ const s = sel(entry.selector);
5925
+ if (typeof s !== "string") return s;
5926
+ return { kind: "stmt", stmt: `type(${s}, ${JSON.stringify(entry.value ?? "")});` };
5927
+ }
5928
+ case "swipe": {
5929
+ const dir = JSON.stringify(entry.direction ?? "up");
5930
+ if (entry.from) {
5931
+ const s = sel(entry.from);
5932
+ if (typeof s !== "string") return s;
5933
+ return { kind: "stmt", stmt: `swipe(${dir}, ${s});` };
5934
+ }
5935
+ return { kind: "stmt", stmt: `swipe(${dir});` };
5936
+ }
5937
+ case "wait_for": {
5938
+ if (!entry.selector) return { kind: "skip", reason: "wait_for missing selector" };
5939
+ const s = sel(entry.selector);
5940
+ if (typeof s !== "string") return s;
5941
+ return { kind: "stmt", stmt: `waitFor(${s}, ${entry.timeoutMs ?? 5e3});` };
5942
+ }
5943
+ case "press_key":
5944
+ return { kind: "stmt", stmt: `pressKey(${JSON.stringify(entry.key ?? "enter")});` };
5945
+ case "app_launch": {
5946
+ const warnings = entry.bundleId ? [
5947
+ {
5948
+ type: "BUNDLE_ID_IGNORED",
5949
+ message: `app_launch recorded with bundleId="${entry.bundleId}" \u2014 DSL appLaunch() reads bundle from APP_BUNDLE_ID env; override at runtime if needed.`
5950
+ }
5951
+ ] : [];
5952
+ return { kind: "stmt", stmt: `appLaunch(${entry.clean ? "true" : "false"});`, warnings };
5953
+ }
5954
+ case "open_deeplink":
5955
+ return { kind: "stmt", stmt: `openDeeplink(${JSON.stringify(entry.url ?? "")});` };
5956
+ case "accept_alert":
5957
+ return {
5958
+ kind: "stmt",
5959
+ stmt: entry.button ? `acceptAlert(${JSON.stringify(entry.button)});` : `acceptAlert();`
5960
+ };
5961
+ case "dismiss_alert":
5962
+ return { kind: "stmt", stmt: `dismissAlert();` };
5963
+ }
5964
+ }
5965
+ __name(renderEntry, "renderEntry");
4953
5966
 
4954
5967
  // src/fixtures/db.ts
4955
5968
  async function createDbClient(databaseUrl) {
@@ -5155,9 +6168,13 @@ async function buildApp(opts = {}) {
5155
6168
  const scenarioRepository = opts.scenarioRepository ?? new FileSystemScenarioRepository({ scenariosDir: scenariosDirAbs });
5156
6169
  const helperRepository = opts.helperRepository ?? new FileSystemHelperRepository({ scenariosDir: scenariosDirAbs });
5157
6170
  const scenarioLoader = new ScenarioLoader(scenarioRepository, helperRepository);
5158
- const sessionRecorder = new SessionRecorder(envConfig.SESSION_LOG_PATH);
5159
- const db = opts.db ?? await createDbClient(envConfig.DATABASE_URL);
5160
- const api = createApiClient(envConfig.API_BASE_URL, logger.child("api"));
6171
+ const sessionRecorder = envConfig.SESSION_LOG_DISABLE || envConfig.SESSION_LOG_PATH === "" ? new NoopSessionRecorder() : new SessionRecorder(envConfig.SESSION_LOG_PATH, { full: envConfig.SESSION_LOG_FULL });
6172
+ const exploration = new ExplorationService({
6173
+ explorationsDir: resolve3(envConfig.explorationsDir)
6174
+ });
6175
+ const dslView = new DslViewService();
6176
+ const db = opts.db ?? (envConfig.DATABASE_URL ? await createDbClient(envConfig.DATABASE_URL) : missingEnvDbClient());
6177
+ const api = envConfig.API_BASE_URL ? createApiClient(envConfig.API_BASE_URL, logger.child("api")) : missingEnvApiClient();
5161
6178
  const shell2 = opts.shell ?? new NodeShellExecutor({
5162
6179
  logger: logger.child("shell"),
5163
6180
  defaultCwd: envConfig.PROJECT_ROOT ?? process.cwd()
@@ -5181,6 +6198,8 @@ async function buildApp(opts = {}) {
5181
6198
  helperRepository,
5182
6199
  scenarioLoader,
5183
6200
  sessionRecorder,
6201
+ exploration,
6202
+ dslView,
5184
6203
  db,
5185
6204
  api,
5186
6205
  shell: shell2,
@@ -5196,6 +6215,32 @@ async function buildApp(opts = {}) {
5196
6215
  };
5197
6216
  }
5198
6217
  __name(buildApp, "buildApp");
6218
+ function missingEnvDbClient() {
6219
+ const fail = /* @__PURE__ */ __name(() => {
6220
+ throw new Error(
6221
+ `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.`
6222
+ );
6223
+ }, "fail");
6224
+ return {
6225
+ query: /* @__PURE__ */ __name(() => fail(), "query"),
6226
+ exec: /* @__PURE__ */ __name(() => fail(), "exec"),
6227
+ close: /* @__PURE__ */ __name(async () => {
6228
+ }, "close")
6229
+ };
6230
+ }
6231
+ __name(missingEnvDbClient, "missingEnvDbClient");
6232
+ function missingEnvApiClient() {
6233
+ const fail = /* @__PURE__ */ __name(() => {
6234
+ throw new Error(
6235
+ `API_BASE_URL is not set in unotest/.env \u2014 required for apiCall. Set it or remove the apiCall call from the scenario.`
6236
+ );
6237
+ }, "fail");
6238
+ return {
6239
+ call: /* @__PURE__ */ __name(() => fail(), "call"),
6240
+ login: /* @__PURE__ */ __name(() => fail(), "login")
6241
+ };
6242
+ }
6243
+ __name(missingEnvApiClient, "missingEnvApiClient");
5199
6244
 
5200
6245
  // src/dsl/variable-scope.ts
5201
6246
  var VariableScope = class {
@@ -5377,6 +6422,24 @@ function truncate2(s, max) {
5377
6422
  }
5378
6423
  __name(truncate2, "truncate");
5379
6424
 
6425
+ // src/util/cli-entry.ts
6426
+ function runMain(main2, errorExitCode) {
6427
+ main2().then(
6428
+ (code) => process.exit(code),
6429
+ (e) => {
6430
+ const msg = e instanceof Error ? e.message : String(e);
6431
+ process.stderr.write(`\u2717 ${msg}
6432
+ `);
6433
+ if (process.env.UNOTEST_DEBUG === "1" && e instanceof Error && e.stack) {
6434
+ process.stderr.write(`${e.stack}
6435
+ `);
6436
+ }
6437
+ process.exit(errorExitCode);
6438
+ }
6439
+ );
6440
+ }
6441
+ __name(runMain, "runMain");
6442
+
5380
6443
  // src/runner/cli.ts
5381
6444
  var HELP = `unotest-mobile \u2014 iOS E2E harness (JS-DSL runner).
5382
6445
 
@@ -5588,11 +6651,4 @@ async function cmdLint() {
5588
6651
  });
5589
6652
  }
5590
6653
  __name(cmdLint, "cmdLint");
5591
- main().then(
5592
- (code) => process.exit(code),
5593
- (e) => {
5594
- console.error("[cli] fatal:", e);
5595
- process.exit(2);
5596
- }
5597
- );
5598
- //# sourceMappingURL=cli.js.map
6654
+ runMain(main, 2);