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