qraftai-runner 0.1.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.
Files changed (3) hide show
  1. package/README.md +91 -0
  2. package/dist/index.js +1299 -0
  3. package/package.json +38 -0
package/dist/index.js ADDED
@@ -0,0 +1,1299 @@
1
+ #!/usr/bin/env node
2
+
3
+ // index.js
4
+ import "dotenv/config";
5
+ import WebSocket from "ws";
6
+ import { createRequire } from "node:module";
7
+ import { writeFileSync, existsSync, statSync, mkdirSync } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+
11
+ // ../server/mobile/appiumManager.js
12
+ import { spawn } from "node:child_process";
13
+ var HOST = process.env.APPIUM_HOST || "127.0.0.1";
14
+ var PORT = parseInt(process.env.APPIUM_PORT_SERVER || "4723");
15
+ var _proc = null;
16
+ var _readyPromise = null;
17
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
18
+ function appiumEndpoint() {
19
+ return { host: HOST, port: PORT };
20
+ }
21
+ async function isUp() {
22
+ try {
23
+ const res = await fetch(`http://${HOST}:${PORT}/status`);
24
+ return res.ok;
25
+ } catch {
26
+ return false;
27
+ }
28
+ }
29
+ async function ensureAppium() {
30
+ if (await isUp()) return appiumEndpoint();
31
+ if (_readyPromise) return _readyPromise;
32
+ _readyPromise = (async () => {
33
+ console.log(`[mobile] starting Appium server on ${HOST}:${PORT}\u2026`);
34
+ _proc = spawn("appium", ["--port", String(PORT), "--log-level", "error"], {
35
+ stdio: "ignore"
36
+ });
37
+ _proc.on("error", (e) => console.error("[mobile] appium spawn error:", e.message));
38
+ _proc.on("exit", () => {
39
+ _proc = null;
40
+ });
41
+ const deadline = Date.now() + 6e4;
42
+ while (Date.now() < deadline) {
43
+ if (await isUp()) {
44
+ console.log("[mobile] Appium server ready");
45
+ return appiumEndpoint();
46
+ }
47
+ await sleep(1e3);
48
+ }
49
+ throw new Error("Appium server did not become ready within 60s");
50
+ })();
51
+ try {
52
+ return await _readyPromise;
53
+ } finally {
54
+ _readyPromise = null;
55
+ }
56
+ }
57
+ function stopAppium() {
58
+ if (_proc) {
59
+ _proc.kill();
60
+ _proc = null;
61
+ }
62
+ }
63
+
64
+ // ../server/mobile/deviceRegistry.js
65
+ import { execFile } from "node:child_process";
66
+ import { promisify } from "node:util";
67
+ var pexec = promisify(execFile);
68
+ var DEFAULT_NEW_COMMAND_TIMEOUT = parseInt(process.env.APPIUM_CMD_TIMEOUT || "120");
69
+ var MJPEG_PORT = parseInt(process.env.MOBILE_MJPEG_PORT || "9100");
70
+ async function listIosSimulators() {
71
+ try {
72
+ const { stdout } = await pexec("xcrun", ["simctl", "list", "devices", "available", "--json"]);
73
+ const data = JSON.parse(stdout);
74
+ const out = [];
75
+ for (const [runtime, devs] of Object.entries(data.devices || {})) {
76
+ if (!/iOS/i.test(runtime)) continue;
77
+ const ver = (runtime.match(/iOS[ -](\d+[-.]\d+)/i)?.[1] || "").replace("-", ".");
78
+ for (const d of devs) {
79
+ if (d.isAvailable === false) continue;
80
+ out.push({ id: d.udid, name: d.name, os: ver, booted: d.state === "Booted" });
81
+ }
82
+ }
83
+ return out;
84
+ } catch {
85
+ return [];
86
+ }
87
+ }
88
+ async function listIosRealDevices() {
89
+ try {
90
+ const { stdout } = await pexec("xcrun", ["xctrace", "list", "devices"]);
91
+ const out = [];
92
+ for (const line of stdout.split(/\r?\n/)) {
93
+ if (/Simulator/i.test(line)) continue;
94
+ const m = /^(.*?)\s+\(([\d.]+)\)\s+\(([0-9A-Fa-f-]{8,})\)\s*$/.exec(line.trim());
95
+ if (m) out.push({ id: m[3], name: m[1], os: m[2] });
96
+ }
97
+ return out;
98
+ } catch {
99
+ return [];
100
+ }
101
+ }
102
+ async function listAvds() {
103
+ const emu = process.env.ANDROID_HOME ? `${process.env.ANDROID_HOME}/emulator/emulator` : "emulator";
104
+ try {
105
+ const { stdout } = await pexec(emu, ["-list-avds"]);
106
+ return stdout.split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
107
+ } catch {
108
+ return [];
109
+ }
110
+ }
111
+ async function listAdbDevices() {
112
+ try {
113
+ const { stdout } = await pexec("adb", ["devices"], { timeout: 5e3 });
114
+ return stdout.split(/\r?\n/).slice(1).map((l) => l.trim()).filter(Boolean).map((l) => {
115
+ const [serial, status] = l.split(/\s+/);
116
+ return { serial, status };
117
+ }).filter((d) => d.serial && d.status === "device");
118
+ } catch {
119
+ return [];
120
+ }
121
+ }
122
+ async function listDevicesDetailed(platform = "android") {
123
+ if (platform === "ios") return listIosDevicesDetailed();
124
+ const avds = await listAvds();
125
+ const adb = await listAdbDevices();
126
+ const real = adb.filter((d) => !d.serial.startsWith("emulator-"));
127
+ const out = [];
128
+ const seen = /* @__PURE__ */ new Set();
129
+ for (const name of avds) {
130
+ if (!seen.has(name)) {
131
+ seen.add(name);
132
+ out.push({ id: name, label: name, kind: "avd" });
133
+ }
134
+ }
135
+ for (const d of real) {
136
+ if (!seen.has(d.serial)) {
137
+ seen.add(d.serial);
138
+ out.push({ id: d.serial, label: `${d.serial} \xB7 phone`, kind: "device" });
139
+ }
140
+ }
141
+ return out;
142
+ }
143
+ async function resolveAdbSerial(device) {
144
+ if (!device) return null;
145
+ const adb = await listAdbDevices();
146
+ const serials = adb.map((d) => d.serial);
147
+ if (serials.includes(device)) return device;
148
+ const want = device.replace(/_/g, " ").trim();
149
+ for (const serial of serials.filter((s) => s.startsWith("emulator-"))) {
150
+ const name = await emulatorAvdName(serial);
151
+ if (name && (name === device || name.replace(/_/g, " ").trim() === want)) return serial;
152
+ }
153
+ return null;
154
+ }
155
+ async function emulatorAvdName(serial) {
156
+ for (const prop of ["ro.boot.qemu.avd_name", "ro.kernel.qemu.avd_name"]) {
157
+ try {
158
+ const { stdout } = await pexec("adb", ["-s", serial, "shell", "getprop", prop], { timeout: 3e3 });
159
+ const name = stdout.trim();
160
+ if (name) return name;
161
+ } catch {
162
+ }
163
+ }
164
+ return "";
165
+ }
166
+ async function listInstalledApps(device) {
167
+ const serial = await resolveAdbSerial(device);
168
+ if (!serial) return [];
169
+ let stdout;
170
+ try {
171
+ ({ stdout } = await pexec("adb", ["-s", serial, "shell", "pm", "list", "packages", "-3"], { timeout: 8e3 }));
172
+ } catch {
173
+ return [];
174
+ }
175
+ const pkgs = stdout.split(/\r?\n/).map((l) => l.replace(/^package:/, "").trim()).filter(Boolean).sort();
176
+ return pkgs.map((p) => ({ package: p, label: prettyPkgLabel(p) }));
177
+ }
178
+ async function verifyDevice(device, appPackage) {
179
+ const serial = await resolveAdbSerial(device);
180
+ if (!serial) return { online: false };
181
+ let appInstalled = null;
182
+ if (appPackage && /^[A-Za-z0-9._]+$/.test(appPackage)) {
183
+ try {
184
+ const { stdout } = await pexec("adb", ["-s", serial, "shell", "pm", "list", "packages", appPackage], { timeout: 5e3 });
185
+ appInstalled = stdout.split(/\r?\n/).some((l) => l.trim() === `package:${appPackage}`);
186
+ } catch {
187
+ appInstalled = null;
188
+ }
189
+ }
190
+ return { online: true, serial, appInstalled };
191
+ }
192
+ function prettyPkgLabel(pkg) {
193
+ const parts = pkg.split(".").filter(Boolean);
194
+ let name = parts[parts.length - 1] || pkg;
195
+ const generic = /* @__PURE__ */ new Set(["android", "app", "application", "mobile", "client", "main", "prod", "release"]);
196
+ if (generic.has(name.toLowerCase()) && parts.length > 1) name = parts[parts.length - 2];
197
+ return name.replace(/[_-]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
198
+ }
199
+ async function listIosDevicesDetailed() {
200
+ const sims = await listIosSimulators();
201
+ const real = await listIosRealDevices();
202
+ const out = [];
203
+ for (const d of real) out.push({ id: d.id, label: `${d.name} \xB7 iPhone${d.os ? ` (iOS ${d.os})` : ""}`, kind: "device", os: d.os });
204
+ for (const s of sims) out.push({ id: s.udid || s.id, label: `${s.name}${s.os ? ` \xB7 iOS ${s.os}` : ""}${s.booted ? " \xB7 booted" : ""}`, kind: "simulator", os: s.os });
205
+ return out;
206
+ }
207
+ var DEVICES = {
208
+ maruti: {
209
+ platform: "android",
210
+ automationName: "UiAutomator2",
211
+ avd: "maruti"
212
+ },
213
+ userPhone: {
214
+ platform: "android",
215
+ automationName: "UiAutomator2",
216
+ avd: "userPhone"
217
+ // NOTE: userPhone returns a blank framebuffer to screenshots with its
218
+ // default GPU mode — prefer maruti for vision/streaming work.
219
+ }
220
+ };
221
+ var DEFAULT_DEVICE = process.env.MOBILE_DEFAULT_DEVICE || "maruti";
222
+ function buildCapabilities(deviceName, app = {}, kind = "avd", overrides = {}, platform = "android") {
223
+ if (platform === "ios") return buildIosCapabilities(deviceName, app, kind, overrides);
224
+ const caps = {
225
+ platformName: "Android",
226
+ "appium:automationName": "UiAutomator2",
227
+ "appium:newCommandTimeout": DEFAULT_NEW_COMMAND_TIMEOUT,
228
+ // Auto-grant uses `adb install -g`, which non-rooted real phones reject
229
+ // (no INSTALL_GRANT_RUNTIME_PERMISSIONS) — only enable it on emulators.
230
+ "appium:autoGrantPermissions": kind !== "device",
231
+ "appium:appWaitActivity": "*",
232
+ // Real (non-rooted) phones can't modify the hidden-API policy global settings
233
+ // (no WRITE_SECURE_SETTINGS) — proceed instead of failing session creation.
234
+ "appium:ignoreHiddenApiPolicyError": true,
235
+ // Live screen stream (MJPEG) for the device view — local only.
236
+ "appium:mjpegServerPort": MJPEG_PORT
237
+ };
238
+ if (kind === "device") {
239
+ caps["appium:udid"] = deviceName;
240
+ caps["appium:noReset"] = true;
241
+ } else {
242
+ const avd = DEVICES[deviceName]?.avd || deviceName || DEVICES[DEFAULT_DEVICE]?.avd;
243
+ if (!avd) throw new Error(`No AVD/device specified and no default configured`);
244
+ caps["appium:avd"] = avd;
245
+ }
246
+ if (app.app) {
247
+ caps["appium:app"] = app.app;
248
+ } else if (app.appPackage) {
249
+ caps["appium:appPackage"] = app.appPackage;
250
+ if (app.appActivity) caps["appium:appActivity"] = app.appActivity;
251
+ }
252
+ return { ...caps, ...overrides };
253
+ }
254
+ function buildIosCapabilities(deviceName, app = {}, kind = "simulator", overrides = {}) {
255
+ const caps = {
256
+ platformName: "iOS",
257
+ "appium:automationName": "XCUITest",
258
+ "appium:newCommandTimeout": DEFAULT_NEW_COMMAND_TIMEOUT,
259
+ "appium:autoAcceptAlerts": false,
260
+ // never auto-dismiss — the agent acts literally
261
+ // Live screen stream (MJPEG) — XCUITest serves it on this port (local only).
262
+ "appium:mjpegServerPort": MJPEG_PORT
263
+ };
264
+ if (kind === "device") {
265
+ caps["appium:udid"] = deviceName;
266
+ caps["appium:noReset"] = true;
267
+ if (process.env.IOS_TEAM_ID) caps["appium:xcodeOrgId"] = process.env.IOS_TEAM_ID;
268
+ caps["appium:xcodeSigningId"] = process.env.IOS_SIGNING_ID || "iPhone Developer";
269
+ if (process.env.IOS_WDA_BUNDLE_ID) caps["appium:updatedWDABundleId"] = process.env.IOS_WDA_BUNDLE_ID;
270
+ } else {
271
+ if (/^[0-9A-Fa-f-]{8,}$/.test(deviceName) && deviceName.includes("-")) caps["appium:udid"] = deviceName;
272
+ else caps["appium:deviceName"] = deviceName || "iPhone 15";
273
+ }
274
+ if (app.app) {
275
+ caps["appium:app"] = app.app;
276
+ } else if (app.appPackage) {
277
+ caps["appium:bundleId"] = app.appPackage;
278
+ }
279
+ return { ...caps, ...overrides };
280
+ }
281
+
282
+ // ../server/mobile/drivers/appiumDriver.js
283
+ import { remote } from "webdriverio";
284
+
285
+ // ../server/mobile/drivers/automationDriver.js
286
+ var AutomationDriver = class {
287
+ /** Open a device session. */
288
+ async start() {
289
+ throw new Error("not implemented");
290
+ }
291
+ /** Tear the session down. Must be safe to call more than once. */
292
+ async stop() {
293
+ throw new Error("not implemented");
294
+ }
295
+ /** Perceive: screenshot + grounding elements + screen signature. */
296
+ async getSnapshot() {
297
+ throw new Error("not implemented");
298
+ }
299
+ /** Execute one action (see mobileActions.js for the verb set). */
300
+ async act(_action) {
301
+ throw new Error("not implemented");
302
+ }
303
+ /** Signature of the current screen, for cache keying (Phase 4). */
304
+ async currentScreenId() {
305
+ throw new Error("not implemented");
306
+ }
307
+ /** Platform string: "android" | "ios" | "web". */
308
+ get platform() {
309
+ return "unknown";
310
+ }
311
+ };
312
+
313
+ // ../server/mobile/appiumSnapshot.js
314
+ function parseBounds(raw) {
315
+ const m = /\[(\d+),(\d+)\]\[(\d+),(\d+)\]/.exec(raw || "");
316
+ if (!m) return null;
317
+ const [x1, y1, x2, y2] = m.slice(1).map(Number);
318
+ return {
319
+ x: x1,
320
+ y: y1,
321
+ width: x2 - x1,
322
+ height: y2 - y1,
323
+ cx: Math.round((x1 + x2) / 2),
324
+ cy: Math.round((y1 + y2) / 2)
325
+ };
326
+ }
327
+ function iosBounds(tag) {
328
+ const x = Number(attr(tag, "x")), y = Number(attr(tag, "y"));
329
+ const w = Number(attr(tag, "width")), h = Number(attr(tag, "height"));
330
+ if ([x, y, w, h].some((n) => Number.isNaN(n))) return null;
331
+ return { x, y, width: w, height: h, cx: Math.round(x + w / 2), cy: Math.round(y + h / 2) };
332
+ }
333
+ function attr(tag, name) {
334
+ const m = new RegExp(`${name}="([^"]*)"`).exec(tag);
335
+ return m ? m[1] : "";
336
+ }
337
+ function decode(s) {
338
+ return s.replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'");
339
+ }
340
+ function normalizeSnapshot(xml, { interactableOnly = false, platform = "android" } = {}) {
341
+ if (!xml) return [];
342
+ const ios = platform === "ios" || /XCUIElementType/.test(xml);
343
+ return ios ? normalizeIos(xml, interactableOnly) : normalizeAndroid(xml, interactableOnly);
344
+ }
345
+ function normalizeAndroid(xml, interactableOnly) {
346
+ const tags = xml.match(/<[a-zA-Z][^>]*?\/?>/g) || [];
347
+ const out = [];
348
+ for (const tag of tags) {
349
+ if (tag.startsWith("</") || tag.startsWith("<?")) continue;
350
+ const className = attr(tag, "class") || tag.replace(/^<([\w.]+).*/, "$1");
351
+ const text = decode(attr(tag, "text"));
352
+ const contentDesc = decode(attr(tag, "content-desc"));
353
+ const resourceId = attr(tag, "resource-id");
354
+ const clickable = attr(tag, "clickable") === "true";
355
+ const enabled = attr(tag, "enabled") !== "false";
356
+ const focusable = attr(tag, "focusable") === "true";
357
+ const bounds = parseBounds(attr(tag, "bounds"));
358
+ const label = text || contentDesc || resourceId.split("/").pop() || "";
359
+ if (interactableOnly && !clickable && !focusable && !text && !contentDesc) continue;
360
+ out.push({
361
+ index: out.length,
362
+ class: className,
363
+ text,
364
+ contentDesc,
365
+ resourceId,
366
+ clickable,
367
+ enabled,
368
+ focusable,
369
+ bounds,
370
+ label
371
+ });
372
+ }
373
+ return out;
374
+ }
375
+ function normalizeIos(xml, interactableOnly) {
376
+ const tags = xml.match(/<XCUIElementType[^>]*?\/?>/g) || [];
377
+ const out = [];
378
+ for (const tag of tags) {
379
+ const className = tag.replace(/^<(XCUIElementType\w+).*/, "$1");
380
+ const name = decode(attr(tag, "name"));
381
+ const labelAttr = decode(attr(tag, "label"));
382
+ const value = decode(attr(tag, "value"));
383
+ const enabled = attr(tag, "enabled") !== "false";
384
+ const visible = attr(tag, "visible") !== "false";
385
+ const accessible = attr(tag, "accessible") === "true";
386
+ const bounds = iosBounds(tag);
387
+ const text = labelAttr || value || "";
388
+ const label = labelAttr || name || value || "";
389
+ const clickable = /Button|Cell|Link|MenuItem|SegmentedControl|Switch|TabBar|StaticText|TextField|SecureTextField|SearchField/.test(className) || accessible;
390
+ if (interactableOnly && !visible) continue;
391
+ if (interactableOnly && !clickable && !text && !name) continue;
392
+ out.push({
393
+ index: out.length,
394
+ class: className,
395
+ text,
396
+ contentDesc: name,
397
+ // map name → contentDesc so downstream label logic works
398
+ resourceId: name,
399
+ // iOS has no resource-id; name is the closest stable id
400
+ clickable,
401
+ enabled,
402
+ focusable: accessible,
403
+ bounds,
404
+ label
405
+ });
406
+ }
407
+ return out;
408
+ }
409
+ function screenSignature(elements) {
410
+ const ids = [
411
+ ...new Set(
412
+ elements.map((e) => e.resourceId || e.label).filter((s) => s && s.length <= 60)
413
+ )
414
+ ].sort().slice(0, 40);
415
+ let h = 5381;
416
+ const str = ids.join("|");
417
+ for (let i = 0; i < str.length; i++) h = (h << 5) + h + str.charCodeAt(i) | 0;
418
+ return (h >>> 0).toString(36);
419
+ }
420
+
421
+ // ../server/mobile/mobileActions.js
422
+ var sleep2 = (ms) => new Promise((r) => setTimeout(r, ms));
423
+ var SMART_WAIT = (process.env.MOBILE_SMART_WAIT ?? "1") !== "0";
424
+ var ELEM_WAIT_MS = parseInt(process.env.MOBILE_ELEM_WAIT_MS || "1200", 10);
425
+ var ELEM_POLL_MS = parseInt(process.env.MOBILE_ELEM_POLL_MS || "150", 10);
426
+ var STABLE_EPS_PX = parseInt(process.env.MOBILE_ELEM_STABLE_EPS || "2", 10);
427
+ var WDIO_CALL_MS = parseInt(process.env.MOBILE_WDIO_CALL_MS || "4000", 10);
428
+ var ASSERT_TIMEOUT_MS = Math.max(0, parseInt(process.env.MOBILE_ASSERT_TIMEOUT_MS || "2500", 10));
429
+ var ASSERT_POLL_MS = Math.max(50, parseInt(process.env.MOBILE_ASSERT_POLL_MS || "250", 10));
430
+ var ASSERT_LEGACY_SOURCE = process.env.MOBILE_ASSERT_LEGACY_SOURCE === "1";
431
+ var ASSERT_NV_WAIT = process.env.MOBILE_ASSERT_NOTVIS_WAIT === "1";
432
+ function wdio(p, ms = WDIO_CALL_MS, label = "wdio") {
433
+ return Promise.race([
434
+ p,
435
+ new Promise((_, rej) => setTimeout(() => rej(new Error(`${label} timeout ${ms}ms`)), ms))
436
+ ]);
437
+ }
438
+ function isIos(driver) {
439
+ if (typeof driver.isIOS === "boolean") return driver.isIOS;
440
+ const cap = driver.capabilities?.platformName || driver.requestedCapabilities?.platformName || "";
441
+ return String(cap).toLowerCase() === "ios";
442
+ }
443
+ function selectorFor(target = {}, ios = false) {
444
+ if (target.accessibilityId) return `~${target.accessibilityId}`;
445
+ if (target.xpath) return target.xpath;
446
+ if (ios) {
447
+ if (target.label) return `-ios predicate string:label == "${esc(target.label)}" OR name == "${esc(target.label)}"`;
448
+ if (target.text) return `-ios predicate string:label == "${esc(target.text)}" OR name == "${esc(target.text)}" OR value == "${esc(target.text)}"`;
449
+ if (target.textContains) return `-ios predicate string:label CONTAINS "${esc(target.textContains)}" OR name CONTAINS "${esc(target.textContains)}" OR value CONTAINS "${esc(target.textContains)}"`;
450
+ if (target.className) return `-ios class chain:**/${esc(target.className)}`;
451
+ if (target.resourceId) return `~${esc(target.resourceId)}`;
452
+ return null;
453
+ }
454
+ if (target.resourceId) return `android=new UiSelector().resourceId("${esc(target.resourceId)}")`;
455
+ if (target.text) return `android=new UiSelector().text("${esc(target.text)}")`;
456
+ if (target.textContains) return `android=new UiSelector().textContains("${esc(target.textContains)}")`;
457
+ if (target.label) return `android=new UiSelector().text("${esc(target.label)}")`;
458
+ if (target.className) return `android=new UiSelector().className("${esc(target.className)}")`;
459
+ return null;
460
+ }
461
+ function esc(s) {
462
+ return String(s).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
463
+ }
464
+ function selectorCandidates(target = {}, ios = false) {
465
+ const out = [];
466
+ const push = (strategy, sel) => {
467
+ if (sel) out.push({ strategy, sel });
468
+ };
469
+ if (target.accessibilityId) push("accessibilityId", `~${esc(target.accessibilityId)}`);
470
+ if (ios) {
471
+ if (target.resourceId) push("resourceId", `~${esc(target.resourceId)}`);
472
+ if (target.text) push("text", `-ios predicate string:label == "${esc(target.text)}" OR name == "${esc(target.text)}" OR value == "${esc(target.text)}"`);
473
+ if (target.label) push("label", `-ios predicate string:label == "${esc(target.label)}" OR name == "${esc(target.label)}"`);
474
+ if (target.textContains) push("textContains", `-ios predicate string:label CONTAINS "${esc(target.textContains)}" OR name CONTAINS "${esc(target.textContains)}" OR value CONTAINS "${esc(target.textContains)}"`);
475
+ if (target.className && /^XCUIElementType/.test(target.className)) {
476
+ push("className", `-ios class chain:**/${esc(target.className)}`);
477
+ }
478
+ } else {
479
+ if (target.resourceId) push("resourceId", `android=new UiSelector().resourceId("${esc(target.resourceId)}")`);
480
+ if (target.text) push("text", `android=new UiSelector().text("${esc(target.text)}")`);
481
+ if (target.label) push("label", `android=new UiSelector().text("${esc(target.label)}")`);
482
+ if (target.textContains) push("textContains", `android=new UiSelector().textContains("${esc(target.textContains)}")`);
483
+ if (target.className) push("className", `android=new UiSelector().className("${esc(target.className)}")`);
484
+ }
485
+ if (target.xpath) push("xpath", target.xpath);
486
+ return out;
487
+ }
488
+ function androidUiSelector(target = {}) {
489
+ if (target.resourceId) return `new UiSelector().resourceId("${esc(target.resourceId)}")`;
490
+ if (target.text) return `new UiSelector().text("${esc(target.text)}")`;
491
+ if (target.textContains) return `new UiSelector().textContains("${esc(target.textContains)}")`;
492
+ if (target.label) return `new UiSelector().text("${esc(target.label)}")`;
493
+ if (target.className) return `new UiSelector().className("${esc(target.className)}")`;
494
+ if (target.accessibilityId) return `new UiSelector().description("${esc(target.accessibilityId)}")`;
495
+ return null;
496
+ }
497
+ function describeTarget(target = {}) {
498
+ const k = Object.keys(target)[0];
499
+ return k ? `${k} "${target[k]}"` : "element";
500
+ }
501
+ var MobileElementError = class extends Error {
502
+ constructor({ code, target, tried = [], message }) {
503
+ super(message);
504
+ this.name = "MobileElementError";
505
+ this.code = code;
506
+ this.target = target;
507
+ this.tried = tried;
508
+ this.recoverable = true;
509
+ }
510
+ toJSON() {
511
+ return {
512
+ name: this.name,
513
+ code: this.code,
514
+ message: this.message,
515
+ target: this.target,
516
+ tried: this.tried,
517
+ recoverable: this.recoverable
518
+ };
519
+ }
520
+ };
521
+ async function waitForElementReady(el, capMs) {
522
+ const deadline = Date.now() + Math.max(0, capMs);
523
+ let lastReason = "absent";
524
+ let prev = null;
525
+ for (; ; ) {
526
+ let exists = false;
527
+ try {
528
+ exists = await wdio(el.isExisting(), WDIO_CALL_MS, "isExisting");
529
+ } catch {
530
+ exists = false;
531
+ }
532
+ if (!exists) {
533
+ lastReason = "absent";
534
+ prev = null;
535
+ } else {
536
+ let shown = false;
537
+ try {
538
+ shown = await wdio(el.isDisplayed(), WDIO_CALL_MS, "isDisplayed");
539
+ } catch {
540
+ shown = false;
541
+ }
542
+ if (!shown) {
543
+ lastReason = "hidden";
544
+ prev = null;
545
+ } else {
546
+ let box = null;
547
+ try {
548
+ const [loc, size] = await Promise.all([
549
+ wdio(el.getLocation(), WDIO_CALL_MS, "getLocation"),
550
+ wdio(el.getSize(), WDIO_CALL_MS, "getSize")
551
+ ]);
552
+ box = { x: loc.x, y: loc.y, w: size.width, h: size.height };
553
+ } catch {
554
+ box = null;
555
+ }
556
+ if (!box) return { ok: true, reason: "displayed" };
557
+ if (prev === null) {
558
+ prev = box;
559
+ return { ok: true, reason: "ready" };
560
+ }
561
+ const still = Math.abs(box.x - prev.x) <= STABLE_EPS_PX && Math.abs(box.y - prev.y) <= STABLE_EPS_PX && Math.abs(box.w - prev.w) <= STABLE_EPS_PX && Math.abs(box.h - prev.h) <= STABLE_EPS_PX;
562
+ if (still) return { ok: true, reason: "stable" };
563
+ lastReason = "unstable";
564
+ prev = box;
565
+ }
566
+ }
567
+ if (Date.now() >= deadline) break;
568
+ await sleep2(Math.min(ELEM_POLL_MS, Math.max(0, deadline - Date.now())));
569
+ }
570
+ return { ok: false, reason: lastReason };
571
+ }
572
+ async function resolveElement(driver, target) {
573
+ const ios = isIos(driver);
574
+ if (!SMART_WAIT) {
575
+ const sel = selectorFor(target, ios);
576
+ if (!sel) throw new Error("No usable selector in target: " + JSON.stringify(target));
577
+ const el = await driver.$(sel);
578
+ if (!await el.isExisting()) throw new Error(`Element not found: ${sel}`);
579
+ return { el, strategy: "legacy", sel };
580
+ }
581
+ const candidates = selectorCandidates(target, ios);
582
+ if (!candidates.length) {
583
+ throw new MobileElementError({
584
+ code: "NO_SELECTOR",
585
+ target,
586
+ tried: [],
587
+ message: `No usable selector in target: ${JSON.stringify(target)}`
588
+ });
589
+ }
590
+ const tried = [];
591
+ const deadline = Date.now() + ELEM_WAIT_MS;
592
+ while (Date.now() < deadline) {
593
+ for (const { strategy, sel } of candidates) {
594
+ let el;
595
+ try {
596
+ el = await driver.$(sel);
597
+ } catch {
598
+ tried.push({ strategy, sel, reason: "bad_selector" });
599
+ continue;
600
+ }
601
+ let exists = false;
602
+ try {
603
+ exists = await wdio(el.isExisting(), WDIO_CALL_MS, "isExisting");
604
+ } catch {
605
+ exists = false;
606
+ }
607
+ if (!exists) {
608
+ tried.push({ strategy, sel, reason: "absent" });
609
+ continue;
610
+ }
611
+ const ready = await waitForElementReady(el, Math.max(ELEM_POLL_MS, deadline - Date.now()));
612
+ if (ready.ok) return { el, strategy, sel };
613
+ tried.push({ strategy, sel, reason: ready.reason });
614
+ }
615
+ if (Date.now() >= deadline) break;
616
+ await sleep2(ELEM_POLL_MS);
617
+ }
618
+ const uniq = dedupeTried(tried);
619
+ const nStrat = new Set(candidates.map((c) => c.strategy)).size;
620
+ throw new MobileElementError({
621
+ code: "ELEMENT_NOT_FOUND",
622
+ target,
623
+ tried,
624
+ message: `Could not locate ${describeTarget(target)} \u2014 not present/displayed on the current screen (tried ${nStrat} selector${nStrat === 1 ? "" : "s"}: ${uniq}).`
625
+ });
626
+ }
627
+ function dedupeTried(tried) {
628
+ const seen = /* @__PURE__ */ new Set();
629
+ const out = [];
630
+ for (const t of tried) {
631
+ const k = `${t.strategy}:${t.reason}`;
632
+ if (!seen.has(k)) {
633
+ seen.add(k);
634
+ out.push(k);
635
+ }
636
+ }
637
+ return out.join(", ");
638
+ }
639
+ async function resolveForAssert(driver, target) {
640
+ const ios = isIos(driver);
641
+ const candidates = SMART_WAIT ? selectorCandidates(target, ios) : selectorFor(target, ios) ? [{ strategy: "legacy", sel: selectorFor(target, ios) }] : [];
642
+ for (const { sel } of candidates) {
643
+ try {
644
+ const el = await driver.$(sel);
645
+ if (await wdio(el.isExisting(), WDIO_CALL_MS, "isExisting").catch(() => false)) return el;
646
+ } catch {
647
+ }
648
+ }
649
+ return null;
650
+ }
651
+ async function w3cTap(driver, x, y) {
652
+ await driver.action("pointer", { parameters: { pointerType: "touch" } }).move({ x: Math.round(x), y: Math.round(y) }).down().pause(60).up().perform();
653
+ }
654
+ async function w3cLongPress(driver, x, y, ms = 800) {
655
+ await driver.action("pointer", { parameters: { pointerType: "touch" } }).move({ x: Math.round(x), y: Math.round(y) }).down().pause(ms).up().perform();
656
+ }
657
+ async function w3cSwipe(driver, x1, y1, x2, y2, duration = 600) {
658
+ await driver.action("pointer", { parameters: { pointerType: "touch" } }).move({ x: Math.round(x1), y: Math.round(y1) }).down().pause(100).move({ duration, x: Math.round(x2), y: Math.round(y2) }).pause(80).up().perform();
659
+ }
660
+ async function elementCenter(el, driver) {
661
+ const loc = await el.getLocation();
662
+ const size = await el.getSize();
663
+ return { x: loc.x + size.width / 2, y: loc.y + size.height / 2 };
664
+ }
665
+ async function pollAssert(check, { timeout = ASSERT_TIMEOUT_MS, interval = ASSERT_POLL_MS } = {}) {
666
+ const deadline = Date.now() + timeout;
667
+ let last = { ok: false, reason: "not evaluated" };
668
+ do {
669
+ try {
670
+ last = await check();
671
+ } catch (e) {
672
+ last = { ok: false, reason: e?.message || String(e) };
673
+ }
674
+ if (last.ok) return last;
675
+ if (Date.now() >= deadline) break;
676
+ await sleep2(Math.min(interval, Math.max(0, deadline - Date.now())));
677
+ } while (Date.now() < deadline);
678
+ throw new Error(last.reason || "assertion failed");
679
+ }
680
+ function assertNeedle(action) {
681
+ return action.value ?? (action.target && (action.target.text || action.target.textContains || action.target.label)) ?? "";
682
+ }
683
+ function textSelector(needle, ios, exact) {
684
+ const n = esc(needle);
685
+ if (ios) {
686
+ const op = exact ? "==" : "CONTAINS";
687
+ return `-ios predicate string:label ${op} "${n}" OR name ${op} "${n}" OR value ${op} "${n}"`;
688
+ }
689
+ return exact ? `android=new UiSelector().text("${n}")` : `android=new UiSelector().textContains("${n}")`;
690
+ }
691
+ function descSelector(needle, exact) {
692
+ const n = esc(needle);
693
+ return exact ? `android=new UiSelector().description("${n}")` : `android=new UiSelector().descriptionContains("${n}")`;
694
+ }
695
+ async function countDisplayed(driver, selector) {
696
+ let els;
697
+ try {
698
+ els = await driver.$$(selector);
699
+ } catch {
700
+ return 0;
701
+ }
702
+ let n = 0;
703
+ const capped = els.slice(0, 25);
704
+ for (const el of capped) {
705
+ if (await wdio(el.isDisplayed(), WDIO_CALL_MS, "isDisplayed").catch(() => false)) n++;
706
+ }
707
+ return n;
708
+ }
709
+ async function isTextVisible(driver, needle, { exact = false } = {}) {
710
+ if (!needle) return { ok: false, reason: 'assert_text: empty "value"' };
711
+ const ios = isIos(driver);
712
+ let matches = await countDisplayed(driver, textSelector(needle, ios, exact));
713
+ if (!matches && !ios) matches = await countDisplayed(driver, descSelector(needle, exact));
714
+ return matches > 0 ? { ok: true, count: matches } : { ok: false, reason: `assert_text failed: "${needle}" not visible on screen`, count: 0 };
715
+ }
716
+ async function clampToScreen(driver, x, y) {
717
+ const { width: w, height: h } = await driver.getWindowSize();
718
+ return {
719
+ x: Math.max(0, Math.min(Math.round(x), w - 1)),
720
+ y: Math.max(0, Math.min(Math.round(y), h - 1))
721
+ };
722
+ }
723
+ async function w3cDoubleTap(driver, x, y) {
724
+ const p = await clampToScreen(driver, x, y);
725
+ await driver.action("pointer", { parameters: { pointerType: "touch" } }).move({ x: p.x, y: p.y }).down().pause(40).up().pause(120).down().pause(40).up().perform();
726
+ }
727
+ async function w3cDrag(driver, x1, y1, x2, y2, holdMs = 600, moveMs = 700) {
728
+ const a = await clampToScreen(driver, x1, y1);
729
+ const b = await clampToScreen(driver, x2, y2);
730
+ await driver.action("pointer", { parameters: { pointerType: "touch" } }).move({ x: a.x, y: a.y }).down().pause(holdMs).move({ duration: 120, x: a.x + (b.x > a.x ? 6 : -6), y: a.y + (b.y > a.y ? 6 : -6) }).move({ duration: moveMs, x: b.x, y: b.y }).pause(200).up().perform();
731
+ }
732
+ async function w3cPinchZoom(driver, cx, cy, dir = "out", spread = 250, moveMs = 600) {
733
+ const half = Math.round(spread / 2);
734
+ const near = Math.max(10, Math.round(spread * 0.12));
735
+ const outer = half, inner = near;
736
+ const [aS, aE, bS, bE] = dir === "in" ? [outer, inner, outer, inner] : [inner, outer, inner, outer];
737
+ const C = await clampToScreen(driver, cx, cy);
738
+ const pA1 = await clampToScreen(driver, C.x - aS, C.y - aS);
739
+ const pA2 = await clampToScreen(driver, C.x - aE, C.y - aE);
740
+ const pB1 = await clampToScreen(driver, C.x + bS, C.y + bS);
741
+ const pB2 = await clampToScreen(driver, C.x + bE, C.y + bE);
742
+ const fingerA = driver.action("pointer", { parameters: { pointerType: "touch" } }).move({ x: pA1.x, y: pA1.y }).down().pause(60).move({ duration: moveMs, x: pA2.x, y: pA2.y }).pause(80).up();
743
+ const fingerB = driver.action("pointer", { parameters: { pointerType: "touch" } }).move({ x: pB1.x, y: pB1.y }).down().pause(60).move({ duration: moveMs, x: pB2.x, y: pB2.y }).pause(80).up();
744
+ await driver.actions([fingerA, fingerB]);
745
+ }
746
+ async function gestureCenter(driver, action) {
747
+ if (action.coords) return await clampToScreen(driver, action.coords.x, action.coords.y);
748
+ if (action.target) {
749
+ const { el } = await resolveElement(driver, action.target);
750
+ const c = await elementCenter(el, driver);
751
+ return await clampToScreen(driver, c.x, c.y);
752
+ }
753
+ const { width: w, height: h } = await driver.getWindowSize();
754
+ return { x: Math.round(w / 2), y: Math.round(h / 2) };
755
+ }
756
+ async function executeMobileAction(driver, action) {
757
+ const { type } = action;
758
+ switch (type) {
759
+ case "tap": {
760
+ if (action.coords) {
761
+ await w3cTap(driver, action.coords.x, action.coords.y);
762
+ return { type, via: "coords" };
763
+ }
764
+ const { el, strategy } = await resolveElement(driver, action.target);
765
+ await el.click();
766
+ return { type, via: "selector", strategy };
767
+ }
768
+ case "type": {
769
+ const text = action.value ?? "";
770
+ console.log(`[mobile] type \u2192 value=${JSON.stringify(text)} target=${JSON.stringify(action.target ?? null)}`);
771
+ if (!text) throw new Error('type action requires a non-empty "value"');
772
+ if (action.target) {
773
+ const { el, strategy } = await resolveElement(driver, action.target);
774
+ if (action.clear !== false) await el.clearValue().catch(() => {
775
+ });
776
+ await el.addValue(text);
777
+ return { type, value: text, via: "selector", strategy };
778
+ }
779
+ await driver.keys(text);
780
+ return { type, value: text, via: "focused" };
781
+ }
782
+ case "longPress": {
783
+ const ms = action.ms ?? 800;
784
+ const pt = action.coords ? action.coords : await elementCenter((await resolveElement(driver, action.target)).el, driver);
785
+ await w3cLongPress(driver, pt.x, pt.y, ms);
786
+ return { type };
787
+ }
788
+ case "swipe":
789
+ case "scroll": {
790
+ const direction = (action.direction || (type === "scroll" ? "down" : "up")).toLowerCase();
791
+ const { width: w, height: h } = await driver.getWindowSize();
792
+ const cx = w / 2, cy = h / 2;
793
+ let x1 = cx, y1 = h * 0.7, x2 = cx, y2 = h * 0.3;
794
+ if (direction === "up") {
795
+ y1 = h * 0.3;
796
+ y2 = h * 0.7;
797
+ } else if (direction === "left") {
798
+ x1 = w * 0.75;
799
+ y1 = cy;
800
+ x2 = w * 0.25;
801
+ y2 = cy;
802
+ } else if (direction === "right") {
803
+ x1 = w * 0.25;
804
+ y1 = cy;
805
+ x2 = w * 0.75;
806
+ y2 = cy;
807
+ }
808
+ await w3cSwipe(driver, x1, y1, x2, y2);
809
+ return { type, direction };
810
+ }
811
+ case "scrollToElement": {
812
+ if (!action.target) throw new Error("scrollToElement requires a target");
813
+ const ios = isIos(driver);
814
+ if (!ios) {
815
+ const childSel = androidUiSelector(action.target);
816
+ if (!childSel) throw new Error("scrollToElement(android): unsupported target for UiScrollable");
817
+ const scrollableSel = action.scrollableResourceId ? `new UiSelector().resourceId("${esc(action.scrollableResourceId)}")` : `new UiSelector().scrollable(true).instance(0)`;
818
+ const sel = `android=new UiScrollable(${scrollableSel}).setMaxSearchSwipes(${action.maxSwipes ?? 20}).scrollIntoView(${childSel})`;
819
+ const el = await driver.$(sel);
820
+ if (!await el.isExisting()) throw new Error(`scrollToElement: ${describeTarget(action.target)} not found after scrolling`);
821
+ return { type, via: "uiscrollable" };
822
+ }
823
+ try {
824
+ const { el } = await resolveElement(driver, action.target);
825
+ await driver.execute("mobile: scroll", { element: el.elementId, toVisible: true });
826
+ if (await el.isDisplayed().catch(() => false)) return { type, via: "mobile:scroll:toVisible" };
827
+ } catch {
828
+ }
829
+ const direction = (action.direction || "down").toLowerCase();
830
+ const maxSwipes = action.maxSwipes ?? 20;
831
+ for (let i = 0; i < maxSwipes; i++) {
832
+ const el = await resolveForAssert(driver, action.target);
833
+ if (el && await el.isDisplayed().catch(() => false)) return { type, via: "mobile:scroll", swipes: i };
834
+ await driver.execute("mobile: scroll", { direction });
835
+ }
836
+ throw new Error(`scrollToElement: ${describeTarget(action.target)} not visible after ${maxSwipes} scrolls`);
837
+ }
838
+ case "doubleTap": {
839
+ const pt = action.coords ? await clampToScreen(driver, action.coords.x, action.coords.y) : await elementCenter((await resolveElement(driver, action.target)).el, driver);
840
+ await w3cDoubleTap(driver, pt.x, pt.y);
841
+ return { type, via: action.coords ? "coords" : "selector" };
842
+ }
843
+ case "pinch":
844
+ case "zoom": {
845
+ const dir = ["in", "out"].includes((action.direction || "").toLowerCase()) ? action.direction.toLowerCase() : type === "zoom" ? "out" : "in";
846
+ const c = await gestureCenter(driver, action);
847
+ await w3cPinchZoom(driver, c.x, c.y, dir, action.spread ?? 250);
848
+ return { type, direction: dir };
849
+ }
850
+ case "drag": {
851
+ let from, to;
852
+ if (action.from && action.to) {
853
+ from = action.from;
854
+ to = action.to;
855
+ } else if (action.target && action.toTarget) {
856
+ from = await elementCenter((await resolveElement(driver, action.target)).el, driver);
857
+ to = await elementCenter((await resolveElement(driver, action.toTarget)).el, driver);
858
+ } else if (action.target && action.to) {
859
+ from = await elementCenter((await resolveElement(driver, action.target)).el, driver);
860
+ to = action.to;
861
+ } else throw new Error("drag requires {from,to} points or {target,toTarget} elements");
862
+ await w3cDrag(driver, from.x, from.y, to.x, to.y, action.holdMs ?? 600);
863
+ return { type };
864
+ }
865
+ case "rotate": {
866
+ const raw = (action.orientation || action.value || action.direction || "").toUpperCase();
867
+ const orientation = raw.startsWith("LAND") ? "LANDSCAPE" : raw.startsWith("PORT") ? "PORTRAIT" : null;
868
+ if (!orientation) throw new Error('rotate requires orientation "LANDSCAPE" or "PORTRAIT"');
869
+ try {
870
+ await driver.setOrientation(orientation);
871
+ } catch (e) {
872
+ throw new Error(`rotate to ${orientation} failed (app may lock orientation): ${e.message}`);
873
+ }
874
+ return { type, orientation, coordsInvalidated: true };
875
+ }
876
+ case "back": {
877
+ if (isIos(driver)) {
878
+ const { width: w, height: h } = await driver.getWindowSize();
879
+ await w3cSwipe(driver, 4, h * 0.5, Math.round(w * 0.6), h * 0.5);
880
+ } else {
881
+ await driver.back();
882
+ }
883
+ return { type };
884
+ }
885
+ case "hideKeyboard": {
886
+ await driver.hideKeyboard().catch(() => {
887
+ });
888
+ return { type };
889
+ }
890
+ case "launchApp": {
891
+ await driver.execute("mobile: activateApp", { appId: action.appId });
892
+ return { type, appId: action.appId };
893
+ }
894
+ case "terminateApp": {
895
+ await driver.execute("mobile: terminateApp", { appId: action.appId });
896
+ return { type, appId: action.appId };
897
+ }
898
+ case "wait": {
899
+ await sleep2(Math.min(action.ms ?? 1e3, 1e4));
900
+ return { type, ms: action.ms ?? 1e3 };
901
+ }
902
+ case "assert_visible": {
903
+ const res = await pollAssert(async () => {
904
+ const el = await resolveForAssert(driver, action.target);
905
+ const ok2 = el ? await wdio(el.isDisplayed(), WDIO_CALL_MS, "isDisplayed").catch(() => false) : false;
906
+ return ok2 ? { ok: true } : { ok: false, reason: `assert_visible failed: ${describeTarget(action.target)} not displayed` };
907
+ });
908
+ return { type, ok: true, assertion: true, count: res.count };
909
+ }
910
+ case "assert_not_visible": {
911
+ const check = async () => {
912
+ const el = await resolveForAssert(driver, action.target);
913
+ const visible = el ? await wdio(el.isDisplayed(), WDIO_CALL_MS, "isDisplayed").catch(() => false) : false;
914
+ return visible ? { ok: false, reason: `assert_not_visible failed: ${describeTarget(action.target)} is still visible` } : { ok: true };
915
+ };
916
+ if (ASSERT_NV_WAIT) {
917
+ await pollAssert(check);
918
+ } else {
919
+ const r = await check();
920
+ if (!r.ok) throw new Error(r.reason);
921
+ }
922
+ return { type, ok: true, assertion: true };
923
+ }
924
+ case "assert_text": {
925
+ const needle = assertNeedle(action);
926
+ const exact = action.exact === true;
927
+ const wantCount = Number.isInteger(action.count) ? action.count : null;
928
+ if (ASSERT_LEGACY_SOURCE) {
929
+ await pollAssert(async () => {
930
+ const source = await driver.getPageSource();
931
+ const ok2 = needle !== "" && (exact ? source === needle : source.includes(needle));
932
+ return ok2 ? { ok: true } : { ok: false, reason: `assert_text failed: "${needle}" not on screen` };
933
+ });
934
+ return { type, ok: true, assertion: true, legacy: true };
935
+ }
936
+ const res = await pollAssert(async () => {
937
+ if (wantCount !== null) {
938
+ const ios = isIos(driver);
939
+ let n = await countDisplayed(driver, textSelector(needle, ios, exact));
940
+ if (!n && !ios) n = await countDisplayed(driver, descSelector(needle, exact));
941
+ return n === wantCount ? { ok: true, count: n } : { ok: false, reason: `assert_text failed: "${needle}" visible ${n}\xD7 (expected ${wantCount})`, count: n };
942
+ }
943
+ return isTextVisible(driver, needle, { exact });
944
+ });
945
+ return { type, ok: true, assertion: true, count: res.count, exact, expectedCount: wantCount };
946
+ }
947
+ case "assert_value": {
948
+ const expected = action.value ?? "";
949
+ const contains = action.match === "contains";
950
+ await pollAssert(async () => {
951
+ const el = await resolveForAssert(driver, action.target);
952
+ if (!el) return { ok: false, reason: `assert_value failed: ${describeTarget(action.target)} not found` };
953
+ let actual = "";
954
+ if (isIos(driver)) {
955
+ actual = await el.getAttribute("value").catch(() => null) ?? await el.getText().catch(() => "") ?? "";
956
+ } else {
957
+ actual = await el.getText().catch(() => "") || await el.getAttribute("text").catch(() => "") || "";
958
+ }
959
+ actual = String(actual);
960
+ const ok2 = contains ? actual.includes(expected) : actual === expected;
961
+ return ok2 ? { ok: true } : { ok: false, reason: `assert_value failed: ${describeTarget(action.target)} value is "${actual}" (expected ${contains ? "to contain " : ""}"${expected}")` };
962
+ });
963
+ return { type, ok: true, assertion: true };
964
+ }
965
+ default:
966
+ throw new Error(`Unsupported mobile action: ${type}`);
967
+ }
968
+ }
969
+
970
+ // ../server/mobile/drivers/appiumDriver.js
971
+ var AppiumDriver = class extends AutomationDriver {
972
+ /**
973
+ * @param {object} opts
974
+ * @param {object} opts.capabilities - W3C/Appium caps
975
+ * @param {object} [opts.connection] - { protocol, hostname, port, path, user, key }
976
+ * defaults to local Appium on 127.0.0.1:4723
977
+ */
978
+ constructor({ capabilities, connection, host, port } = {}) {
979
+ super();
980
+ this.capabilities = capabilities;
981
+ this.connection = connection || {
982
+ protocol: "http",
983
+ hostname: host || "127.0.0.1",
984
+ port: port || 4723,
985
+ path: "/"
986
+ };
987
+ this.driver = null;
988
+ }
989
+ get platform() {
990
+ return (this.capabilities?.platformName || "android").toLowerCase();
991
+ }
992
+ async start() {
993
+ if (this.driver) return;
994
+ const c = this.connection;
995
+ this.driver = await remote({
996
+ protocol: c.protocol,
997
+ hostname: c.hostname,
998
+ port: c.port,
999
+ path: c.path,
1000
+ ...c.user && { user: c.user },
1001
+ ...c.key && { key: c.key },
1002
+ logLevel: "error",
1003
+ connectionRetryTimeout: 3e5,
1004
+ // cloud real-device allocation can be slow
1005
+ capabilities: this.capabilities
1006
+ });
1007
+ }
1008
+ async stop() {
1009
+ if (!this.driver) return;
1010
+ const d = this.driver;
1011
+ this.driver = null;
1012
+ await d.deleteSession().catch(() => {
1013
+ });
1014
+ }
1015
+ _assert() {
1016
+ if (!this.driver) throw new Error("AppiumDriver not started");
1017
+ }
1018
+ async screenshot() {
1019
+ this._assert();
1020
+ return this.driver.takeScreenshot();
1021
+ }
1022
+ async source() {
1023
+ this._assert();
1024
+ return this.driver.getPageSource();
1025
+ }
1026
+ async getSnapshot() {
1027
+ this._assert();
1028
+ const screenshot = await this.screenshot();
1029
+ let elements = [];
1030
+ try {
1031
+ elements = normalizeSnapshot(await this.source(), { interactableOnly: true, platform: this.platform });
1032
+ } catch (e) {
1033
+ console.warn("[mobile] getPageSource failed, proceeding screenshot-only:", e.message);
1034
+ }
1035
+ return { screenshot, elements, screenId: screenSignature(elements) };
1036
+ }
1037
+ async currentScreenId() {
1038
+ const elements = normalizeSnapshot(await this.source(), { interactableOnly: true, platform: this.platform });
1039
+ return screenSignature(elements);
1040
+ }
1041
+ async act(action) {
1042
+ this._assert();
1043
+ return executeMobileAction(this.driver, action);
1044
+ }
1045
+ /** Begin screen recording (best-effort). */
1046
+ async startRecording() {
1047
+ this._assert();
1048
+ try {
1049
+ await this.driver.startRecordingScreen({
1050
+ forceRestart: true,
1051
+ timeLimit: 1800,
1052
+ bitRate: parseInt(process.env.MOBILE_REC_BITRATE || "1500000")
1053
+ });
1054
+ return true;
1055
+ } catch (e) {
1056
+ console.warn("[mobile] startRecordingScreen failed:", e.message);
1057
+ return false;
1058
+ }
1059
+ }
1060
+ /** Stop recording and return the base64 mp4 (or null on failure). */
1061
+ async stopRecording() {
1062
+ if (!this.driver) return null;
1063
+ try {
1064
+ return await this.driver.stopRecordingScreen();
1065
+ } catch (e) {
1066
+ console.warn("[mobile] stopRecordingScreen failed:", e.message);
1067
+ return null;
1068
+ }
1069
+ }
1070
+ };
1071
+
1072
+ // index.js
1073
+ var VERSION = (() => {
1074
+ const req = createRequire(import.meta.url);
1075
+ for (const p of ["./package.json", "../package.json"]) {
1076
+ try {
1077
+ return req(p).version;
1078
+ } catch {
1079
+ }
1080
+ }
1081
+ return "0.0.0";
1082
+ })();
1083
+ function parseArgs(argv) {
1084
+ const out = {};
1085
+ for (let i = 0; i < argv.length; i++) {
1086
+ const a = argv[i];
1087
+ if (a === "--server" || a === "-s") out.server = argv[++i];
1088
+ else if (a === "--token" || a === "-t") out.token = argv[++i];
1089
+ else if (a === "--name" || a === "-n") out.name = argv[++i];
1090
+ else if (a === "--help" || a === "-h") out.help = true;
1091
+ }
1092
+ return out;
1093
+ }
1094
+ var args = parseArgs(process.argv.slice(2));
1095
+ if (args.help) {
1096
+ console.log(`QraftAI Runner v${VERSION}
1097
+ Bridges a local Android device/emulator to your QraftAI workspace.
1098
+
1099
+ Usage:
1100
+ qraftai-runner --server <wss-url> --token <token> [--name <label>]
1101
+
1102
+ Options:
1103
+ -s, --server wss://<your-qraftai-host>/runner-stream (or QRAFTAI_SERVER)
1104
+ -t, --token org-scoped runner token from the Mobile tab (or QRAFTAI_TOKEN)
1105
+ -n, --name a label shown in the app for this machine (or QRAFTAI_RUNNER_NAME)
1106
+
1107
+ Prerequisites (on this machine): JDK 17+, Android SDK (adb + emulator),
1108
+ Appium 2 + uiautomator2, and a device (AVD or USB phone with USB debugging).`);
1109
+ process.exit(0);
1110
+ }
1111
+ var SERVER = args.server || process.env.QRAFTAI_SERVER;
1112
+ var TOKEN = args.token || process.env.QRAFTAI_TOKEN;
1113
+ var NAME = args.name || process.env.QRAFTAI_RUNNER_NAME || "";
1114
+ if (!SERVER || !TOKEN) {
1115
+ console.error("\u2716 --server and --token are required (or QRAFTAI_SERVER / QRAFTAI_TOKEN).");
1116
+ console.error(" Get them from the QraftAI Mobile tab \u2192 Local \u2192 Connect your machine.");
1117
+ process.exit(1);
1118
+ }
1119
+ var sessions = /* @__PURE__ */ new Map();
1120
+ var sessionChains = /* @__PURE__ */ new Map();
1121
+ var ws = null;
1122
+ var reconnectDelay = 2e3;
1123
+ var MAX_RECONNECT_DELAY = 3e4;
1124
+ var closing = false;
1125
+ function sendMsg(type, payload = {}, reqId) {
1126
+ if (ws && ws.readyState === WebSocket.OPEN) {
1127
+ ws.send(JSON.stringify({ type, payload, ...reqId && { reqId } }));
1128
+ }
1129
+ }
1130
+ var ok = (reqId, result) => sendMsg("RESULT", { result }, reqId);
1131
+ var err = (reqId, message) => sendMsg("ERROR", { message }, reqId);
1132
+ var HANDLERS = {
1133
+ async OPEN_SESSION({ sessionId, device, kind = "avd", platform = "android", app }) {
1134
+ if (sessions.has(sessionId)) return { platform: sessions.get(sessionId).platform };
1135
+ const resolvedApp = await resolveApp(app);
1136
+ const { host, port } = await ensureAppium();
1137
+ const capabilities = buildCapabilities(device, resolvedApp, kind, {}, platform);
1138
+ const driver = new AppiumDriver({
1139
+ capabilities,
1140
+ connection: { protocol: "http", hostname: host, port, path: "/" }
1141
+ });
1142
+ await driver.start();
1143
+ sessions.set(sessionId, driver);
1144
+ console.log(`\u25B6 session ${sessionId} started (${platform} ${kind}: ${device || "default"})`);
1145
+ return { platform: driver.platform };
1146
+ },
1147
+ async LIST_APPS({ device }) {
1148
+ return { apps: await listInstalledApps(device) };
1149
+ },
1150
+ async VERIFY_DEVICE({ device, appPackage }) {
1151
+ return verifyDevice(device, appPackage);
1152
+ },
1153
+ async SNAPSHOT({ sessionId }) {
1154
+ return driverFor(sessionId).getSnapshot();
1155
+ },
1156
+ async ACT({ sessionId, action }) {
1157
+ return driverFor(sessionId).act(action);
1158
+ },
1159
+ async SCREEN_ID({ sessionId }) {
1160
+ return { screenId: await driverFor(sessionId).currentScreenId() };
1161
+ },
1162
+ async START_REC({ sessionId }) {
1163
+ return { ok: await driverFor(sessionId).startRecording() };
1164
+ },
1165
+ async STOP_REC({ sessionId }) {
1166
+ return { mp4: await driverFor(sessionId).stopRecording() };
1167
+ },
1168
+ async CLOSE_SESSION({ sessionId }) {
1169
+ const d = sessions.get(sessionId);
1170
+ if (d) {
1171
+ sessions.delete(sessionId);
1172
+ await d.stop().catch(() => {
1173
+ });
1174
+ console.log(`\u25A0 session ${sessionId} ended`);
1175
+ }
1176
+ sessionChains.delete(sessionId);
1177
+ return { ok: true };
1178
+ },
1179
+ async LIST_DEVICES({ platform = "android" }) {
1180
+ return { devices: await listDevicesDetailed(platform) };
1181
+ }
1182
+ };
1183
+ function driverFor(sessionId) {
1184
+ const d = sessions.get(sessionId);
1185
+ if (!d) throw new Error(`no such session: ${sessionId}`);
1186
+ return d;
1187
+ }
1188
+ function runSerialized(sessionId, fn) {
1189
+ const prev = sessionChains.get(sessionId) || Promise.resolve();
1190
+ const next = prev.then(() => fn(), () => fn());
1191
+ sessionChains.set(sessionId, next.then(() => {
1192
+ }, () => {
1193
+ }));
1194
+ return next;
1195
+ }
1196
+ async function resolveApp(app) {
1197
+ if (!app) return {};
1198
+ if (app.apkId && app.downloadToken) return { app: await ensureApk(app.apkId, app.downloadToken) };
1199
+ if (app.appPackage) return { appPackage: app.appPackage, appActivity: app.appActivity };
1200
+ if (app.app) return { app: app.app };
1201
+ return {};
1202
+ }
1203
+ var APK_CACHE = join(tmpdir(), "qraftai-runner-apks");
1204
+ var API_BASE = SERVER.replace(/^ws/i, "http").replace(/\/runner-stream.*$/i, "");
1205
+ async function ensureApk(apkId, token) {
1206
+ mkdirSync(APK_CACHE, { recursive: true });
1207
+ const dest = join(APK_CACHE, `${apkId}.apk`);
1208
+ if (existsSync(dest) && statSync(dest).size > 0) return dest;
1209
+ const url = `${API_BASE}/api/mobile/runner-apk/${encodeURIComponent(apkId)}?t=${encodeURIComponent(token)}`;
1210
+ console.log(`\u21E9 downloading app ${apkId}\u2026`);
1211
+ const res = await fetch(url);
1212
+ if (!res.ok) throw new Error(`app download failed (HTTP ${res.status})`);
1213
+ writeFileSync(dest, Buffer.from(await res.arrayBuffer()));
1214
+ console.log(`\u21E9 app ready (${Math.round(statSync(dest).size / 1048576)} MB)`);
1215
+ return dest;
1216
+ }
1217
+ async function reportDevices() {
1218
+ for (const platform of ["android"]) {
1219
+ try {
1220
+ const devices = await listDevicesDetailed(platform);
1221
+ sendMsg("DEVICES", { platform, devices });
1222
+ } catch {
1223
+ }
1224
+ }
1225
+ }
1226
+ function connect() {
1227
+ const sep = SERVER.includes("?") ? "&" : "?";
1228
+ const url = `${SERVER}${sep}token=${encodeURIComponent(TOKEN)}${NAME ? `&name=${encodeURIComponent(NAME)}` : ""}`;
1229
+ console.log(`\u2026 connecting to ${SERVER}`);
1230
+ ws = new WebSocket(url);
1231
+ ws.on("open", async () => {
1232
+ reconnectDelay = 2e3;
1233
+ console.log(`\u2705 connected to QraftAI${NAME ? ` as "${NAME}"` : ""}`);
1234
+ sendMsg("HELLO", { version: VERSION, name: NAME, platforms: ["android"] });
1235
+ await reportDevices();
1236
+ });
1237
+ ws.on("message", async (raw) => {
1238
+ let msg;
1239
+ try {
1240
+ msg = JSON.parse(raw.toString());
1241
+ } catch {
1242
+ return;
1243
+ }
1244
+ const { type, payload = {}, reqId } = msg;
1245
+ if (type === "REGISTERED") {
1246
+ console.log(` registered (runner id: ${payload.runnerId})`);
1247
+ return;
1248
+ }
1249
+ if (type === "ERROR" && !reqId) {
1250
+ console.error(`\u2716 server: ${payload.message || "error"}`);
1251
+ return;
1252
+ }
1253
+ const handler = HANDLERS[type];
1254
+ if (!handler) {
1255
+ if (reqId) err(reqId, `unknown method: ${type}`);
1256
+ return;
1257
+ }
1258
+ const sid = payload?.sessionId;
1259
+ try {
1260
+ const result = await (sid ? runSerialized(sid, () => handler(payload)) : handler(payload));
1261
+ ok(reqId, result);
1262
+ } catch (e) {
1263
+ console.error(`\u2716 ${type} failed: ${e.message}`);
1264
+ err(reqId, e.message);
1265
+ }
1266
+ });
1267
+ ws.on("close", async (code) => {
1268
+ for (const [id, d] of sessions) {
1269
+ sessions.delete(id);
1270
+ await d.stop().catch(() => {
1271
+ });
1272
+ }
1273
+ sessionChains.clear();
1274
+ if (closing) return;
1275
+ if (code === 4401) {
1276
+ console.error("\u2716 authentication failed \u2014 the token is invalid or expired. Get a fresh one from the Mobile tab.");
1277
+ process.exit(1);
1278
+ }
1279
+ console.warn(`\u2026 disconnected (code ${code}); reconnecting in ${Math.round(reconnectDelay / 1e3)}s`);
1280
+ setTimeout(connect, reconnectDelay);
1281
+ reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY);
1282
+ });
1283
+ ws.on("error", (e) => console.warn(`\u2026 socket error: ${e.message}`));
1284
+ }
1285
+ async function shutdown() {
1286
+ closing = true;
1287
+ console.log("\n\u2026 shutting down runner");
1288
+ for (const [, d] of sessions) await d.stop().catch(() => {
1289
+ });
1290
+ try {
1291
+ ws?.close();
1292
+ } catch {
1293
+ }
1294
+ stopAppium();
1295
+ process.exit(0);
1296
+ }
1297
+ process.on("SIGINT", shutdown);
1298
+ process.on("SIGTERM", shutdown);
1299
+ connect();