@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.
- package/.claude/skills/write-e2e-test.md +373 -261
- package/CHANGELOG.md +457 -0
- package/README.md +11 -4
- package/bin/mcp.js +66 -8
- package/dist/mcp/server.js +2262 -408
- package/dist/runner/cli.js +1237 -181
- package/dist/runner/doctor.js +0 -1
- package/dist/runner/init.js +103 -48
- package/dist/runner/install.js +1967 -0
- package/package.json +10 -3
- package/dist/mcp/server.js.map +0 -1
- package/dist/runner/cli.js.map +0 -1
- package/dist/runner/doctor.js.map +0 -1
- package/dist/runner/init.js.map +0 -1
package/dist/mcp/server.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
|
198
|
+
// specific directory (e.g. monorepo root).
|
|
185
199
|
PROJECT_ROOT: z.string().optional(),
|
|
186
|
-
// DATABASE_URL —
|
|
187
|
-
// (
|
|
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
|
|
194
|
-
|
|
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
|
|
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
|
|
199
|
-
//
|
|
200
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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"))
|
|
421
|
-
|
|
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
|
-
|
|
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((
|
|
691
|
-
child.once("exit", () =>
|
|
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
|
-
|
|
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
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
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
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
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
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
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
|
|
966
|
-
|
|
967
|
-
|
|
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
|
-
|
|
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
|
-
|
|
994
|
-
|
|
1338
|
+
return this.withFreshSession(
|
|
1339
|
+
slot,
|
|
1340
|
+
(session) => session.client.screenshot(session.getSessionId())
|
|
1341
|
+
);
|
|
995
1342
|
}
|
|
996
1343
|
async a11yTree(slot, _opts = {}) {
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
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
|
|
1942
|
+
var INTERACTIVE_ROLES = /* @__PURE__ */ new Set([
|
|
1494
1943
|
"button",
|
|
1495
1944
|
"textfield",
|
|
1496
|
-
"
|
|
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
|
-
|
|
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
|
|
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 (
|
|
1523
|
-
|
|
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
|
-
|
|
1535
|
-
|
|
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
|
|
1976
|
+
return out;
|
|
1538
1977
|
}
|
|
1539
|
-
__name(
|
|
1540
|
-
function
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
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(
|
|
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
|
|
2024
|
+
function indexOnScreenDuplicates(nodes) {
|
|
1561
2025
|
const counts = /* @__PURE__ */ new Map();
|
|
1562
|
-
collectTestIds(
|
|
1563
|
-
|
|
1564
|
-
|
|
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(
|
|
2108
|
+
__name(buildSemanticTree, "buildSemanticTree");
|
|
1567
2109
|
var TreeInspector = class {
|
|
1568
2110
|
static {
|
|
1569
2111
|
__name(this, "TreeInspector");
|
|
1570
2112
|
}
|
|
1571
2113
|
/**
|
|
1572
|
-
*
|
|
1573
|
-
*
|
|
1574
|
-
*
|
|
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
|
-
|
|
1596
|
-
|
|
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
|
|
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(
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
3557
|
-
const path =
|
|
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 =
|
|
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
|
|
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(
|
|
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 =
|
|
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 =
|
|
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
|
|
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(
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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 (!
|
|
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
|
|
4936
|
-
import { dirname as
|
|
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
|
-
|
|
5610
|
+
full;
|
|
5611
|
+
constructor(path, opts = {}) {
|
|
4943
5612
|
this.path = resolve2(process.cwd(), path);
|
|
4944
|
-
|
|
5613
|
+
this.full = opts.full ?? false;
|
|
5614
|
+
mkdirSync3(dirname3(this.path), { recursive: true });
|
|
4945
5615
|
}
|
|
4946
5616
|
record(action) {
|
|
4947
|
-
const
|
|
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
|
-
|
|
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
|
|
5164
|
-
|
|
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/
|
|
5205
|
-
|
|
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.
|
|
5241
|
-
*
|
|
5242
|
-
*
|
|
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
|
-
*
|
|
5245
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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/
|
|
5262
|
-
|
|
5263
|
-
|
|
5264
|
-
|
|
5265
|
-
|
|
5266
|
-
|
|
5267
|
-
|
|
5268
|
-
|
|
5269
|
-
|
|
5270
|
-
|
|
5271
|
-
|
|
5272
|
-
|
|
5273
|
-
|
|
5274
|
-
|
|
5275
|
-
|
|
5276
|
-
|
|
5277
|
-
|
|
5278
|
-
|
|
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(
|
|
5282
|
-
|
|
5283
|
-
|
|
5284
|
-
|
|
5285
|
-
|
|
5286
|
-
|
|
5287
|
-
|
|
5288
|
-
);
|
|
5289
|
-
|
|
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/
|
|
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:
|
|
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:
|
|
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:
|
|
5360
|
-
mode:
|
|
6553
|
+
device: z2.string(),
|
|
6554
|
+
mode: z2.enum(["outline", "full"]).optional()
|
|
5361
6555
|
}
|
|
5362
6556
|
},
|
|
5363
6557
|
async ({ device, mode }) => {
|
|
5364
|
-
const m = mode ?? "
|
|
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
|
-
|
|
5368
|
-
|
|
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
|
|
5551
|
-
var SelectorInput =
|
|
6603
|
+
import { z as z3 } from "zod";
|
|
6604
|
+
var SelectorInput = z3.preprocess(
|
|
5552
6605
|
(v) => typeof v === "string" ? safeJsonParse(v) : v,
|
|
5553
|
-
|
|
5554
|
-
testId:
|
|
5555
|
-
text:
|
|
5556
|
-
label:
|
|
5557
|
-
ordinal:
|
|
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:
|
|
6632
|
+
device: z3.string(),
|
|
5580
6633
|
selector: SelectorInput
|
|
5581
6634
|
}
|
|
5582
6635
|
},
|
|
5583
|
-
async ({ device, selector }) => this.
|
|
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.
|
|
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-${
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
6019
|
-
|
|
6020
|
-
|
|
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
|