@irsprs/mobwright 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.
package/dist/index.js ADDED
@@ -0,0 +1,1163 @@
1
+ // src/fixtures/device.ts
2
+ import { test as base } from "@playwright/test";
3
+
4
+ // src/appium/session.ts
5
+ import { remote } from "webdriverio";
6
+
7
+ // src/types.ts
8
+ var Platform = /* @__PURE__ */ ((Platform2) => {
9
+ Platform2["ANDROID"] = "android";
10
+ Platform2["IOS"] = "ios";
11
+ return Platform2;
12
+ })(Platform || {});
13
+
14
+ // src/appium/capabilities.ts
15
+ function buildCapabilities(project) {
16
+ const { platform, device, buildPath } = project.use;
17
+ const base2 = {
18
+ // W3C capabilities require platformName as a top-level property
19
+ platformName: platformNameFor(platform)
20
+ };
21
+ if (platform === "android" /* ANDROID */) {
22
+ const caps = {
23
+ ...base2,
24
+ "appium:automationName": "UiAutomator2",
25
+ "appium:deviceName": device.name,
26
+ "appium:avd": device.name,
27
+ ...buildPath ? { "appium:app": resolveAppPath(buildPath) } : {},
28
+ "appium:autoGrantPermissions": true,
29
+ "appium:avdLaunchTimeout": 12e4,
30
+ "appium:avdReadyTimeout": 12e4,
31
+ "appium:noReset": false,
32
+ "appium:fullReset": false,
33
+ "appium:enforceAppInstall": false,
34
+ "appium:clearSystemFiles": true,
35
+ // Wait for any activity to become foreground (handles onboarding/splash screens)
36
+ "appium:appWaitActivity": "*",
37
+ "appium:appWaitDuration": 3e4
38
+ };
39
+ if (project.use.appActivity) {
40
+ caps["appium:appActivity"] = project.use.appActivity;
41
+ }
42
+ if (project.use.appPackage) {
43
+ caps["appium:appPackage"] = project.use.appPackage;
44
+ }
45
+ return caps;
46
+ }
47
+ if (platform === "ios" /* IOS */) {
48
+ const caps = {
49
+ ...base2,
50
+ "appium:automationName": "XCUITest",
51
+ "appium:deviceName": device.name,
52
+ "appium:autoAcceptAlerts": true,
53
+ "appium:noReset": false,
54
+ "appium:newCommandTimeout": 120,
55
+ // Give the simulator time to launch Rosetta-translated apps
56
+ "appium:appActivateTimeout": 12e4,
57
+ "appium:wdaLaunchTimeout": 18e4,
58
+ "appium:wdaConnectionTimeout": 18e4,
59
+ "appium:waitForQuiescenceTimeout": 6e4
60
+ };
61
+ if (buildPath) {
62
+ caps["appium:app"] = resolveAppPath(buildPath);
63
+ }
64
+ if (project.use.bundleId) {
65
+ caps["appium:bundleId"] = project.use.bundleId;
66
+ }
67
+ if (project.use.platformVersion) {
68
+ caps["appium:platformVersion"] = project.use.platformVersion;
69
+ }
70
+ if (project.use.udid) {
71
+ caps["appium:udid"] = project.use.udid;
72
+ }
73
+ return caps;
74
+ }
75
+ throw new Error(`Unsupported platform: ${String(platform)}`);
76
+ }
77
+ function platformNameFor(platform) {
78
+ switch (platform) {
79
+ case "android" /* ANDROID */:
80
+ return "Android";
81
+ case "ios" /* IOS */:
82
+ return "iOS";
83
+ default:
84
+ throw new Error(`Unknown platform: ${String(platform)}`);
85
+ }
86
+ }
87
+ function resolveAppPath(p) {
88
+ if (p.startsWith("~")) {
89
+ const home = process.env.HOME ?? "";
90
+ return p.replace(/^~/, home);
91
+ }
92
+ if (p.startsWith("./") || p.startsWith("../")) {
93
+ return new URL(p, `file://${process.cwd()}/`).pathname;
94
+ }
95
+ return p;
96
+ }
97
+
98
+ // src/appium/session.ts
99
+ async function createSession(project, options = {}) {
100
+ const capabilities = buildCapabilities(project);
101
+ return await remote({
102
+ hostname: options.hostname ?? "127.0.0.1",
103
+ port: options.port ?? 4723,
104
+ path: options.path ?? "/",
105
+ logLevel: options.logLevel ?? "warn",
106
+ capabilities
107
+ });
108
+ }
109
+ async function destroySession(browser) {
110
+ try {
111
+ await browser.deleteSession();
112
+ } catch (err) {
113
+ console.warn("[mobwright] session teardown error (ignored):", err);
114
+ }
115
+ }
116
+
117
+ // src/device/selectors.ts
118
+ function parseSelector(selector) {
119
+ if (selector.startsWith("~")) {
120
+ return { strategy: "accessibility-id", value: selector.slice(1) };
121
+ }
122
+ if (selector.startsWith("#")) {
123
+ return { strategy: "id", value: selector.slice(1) };
124
+ }
125
+ if (selector.startsWith("//") || selector.startsWith("(")) {
126
+ return { strategy: "xpath", value: selector };
127
+ }
128
+ return { strategy: "accessibility-id", value: selector };
129
+ }
130
+ function toWdioSelector(parsed, platform) {
131
+ switch (parsed.strategy) {
132
+ case "accessibility-id":
133
+ return `~${parsed.value}`;
134
+ case "id":
135
+ if (platform === "android" /* ANDROID */) {
136
+ return `//*[contains(@resource-id, ":id/${parsed.value}") or @resource-id="${parsed.value}"]`;
137
+ }
138
+ return `//*[@name="${parsed.value}"]`;
139
+ case "xpath":
140
+ return parsed.value;
141
+ case "text":
142
+ if (platform === "android" /* ANDROID */) {
143
+ return `//*[@text="${parsed.value}"]`;
144
+ }
145
+ return `//*[@label="${parsed.value}" or @name="${parsed.value}" or @value="${parsed.value}"]`;
146
+ default:
147
+ throw new Error(`Unknown selector strategy: ${String(parsed.strategy)}`);
148
+ }
149
+ }
150
+
151
+ // src/utils/retry.ts
152
+ async function pollUntil(fn, options = {}) {
153
+ const timeout = options.timeout ?? 5e3;
154
+ const interval = options.interval ?? 100;
155
+ const message = options.message ?? `pollUntil timed out after ${timeout}ms`;
156
+ const deadline = Date.now() + timeout;
157
+ let lastError;
158
+ while (Date.now() < deadline) {
159
+ try {
160
+ const result = await fn();
161
+ if (result) return result;
162
+ } catch (err) {
163
+ lastError = err;
164
+ }
165
+ await sleep(interval);
166
+ }
167
+ const causeMsg = lastError instanceof Error ? `: ${lastError.message}` : "";
168
+ throw new Error(`${message}${causeMsg}`);
169
+ }
170
+ function sleep(ms) {
171
+ return new Promise((resolve) => setTimeout(resolve, ms));
172
+ }
173
+
174
+ // src/device/locator.ts
175
+ var Locator = class {
176
+ selector;
177
+ browser;
178
+ platform;
179
+ defaultTimeout;
180
+ constructor(browser, platform, selector, options = {}) {
181
+ this.browser = browser;
182
+ this.platform = platform;
183
+ this.selector = selector;
184
+ this.defaultTimeout = options.timeout ?? 5e3;
185
+ }
186
+ /**
187
+ * Wait for the element to be present in the tree AND displayed.
188
+ * Returns the wdio element handle once ready.
189
+ */
190
+ async waitForElement(timeout = this.defaultTimeout) {
191
+ const wdioSelector = toWdioSelector(parseSelector(this.selector), this.platform);
192
+ return pollUntil(
193
+ async () => {
194
+ const el = this.browser.$(wdioSelector);
195
+ const exists = await el.isExisting();
196
+ if (!exists) return null;
197
+ const displayed = await el.isDisplayed();
198
+ if (!displayed) return null;
199
+ const enabled = await el.isEnabled();
200
+ if (!enabled) return null;
201
+ return el;
202
+ },
203
+ {
204
+ timeout,
205
+ message: `Locator "${this.selector}" not found or not visible after ${timeout}ms`
206
+ }
207
+ );
208
+ }
209
+ /**
210
+ * Tap (click) the element. Auto-waits until visible + enabled.
211
+ */
212
+ async tap() {
213
+ const el = await this.waitForElement();
214
+ await el.click();
215
+ }
216
+ /**
217
+ * Type text into the element. Auto-waits until visible.
218
+ * Clears existing content first.
219
+ */
220
+ async fill(text) {
221
+ const el = await this.waitForElement();
222
+ await el.clearValue();
223
+ await el.setValue(text);
224
+ }
225
+ /**
226
+ * Get the visible text of the element.
227
+ */
228
+ async getText() {
229
+ const el = await this.waitForElement();
230
+ return await el.getText() ?? "";
231
+ }
232
+ /**
233
+ * Check if the element is currently visible (no waiting).
234
+ * Returns false if not present.
235
+ */
236
+ async isVisible() {
237
+ try {
238
+ const wdioSelector = toWdioSelector(parseSelector(this.selector), this.platform);
239
+ const el = this.browser.$(wdioSelector);
240
+ if (!await el.isExisting()) return false;
241
+ return await el.isDisplayed();
242
+ } catch {
243
+ return false;
244
+ }
245
+ }
246
+ /**
247
+ * Wait for the element to become visible.
248
+ * Useful for explicit waits without performing an action.
249
+ */
250
+ async waitFor(options = {}) {
251
+ await this.waitForElement(options.timeout);
252
+ }
253
+ /**
254
+ * Press and hold the element for `duration` ms (long-press).
255
+ * Default duration: 1000 ms.
256
+ */
257
+ async tapAndHold(options = {}) {
258
+ const el = await this.waitForElement();
259
+ const duration = options.duration ?? 1e3;
260
+ const location = await el.getLocation();
261
+ const size = await el.getSize();
262
+ const x = Math.round(location.x + size.width / 2);
263
+ const y = Math.round(location.y + size.height / 2);
264
+ await this.browser.performActions([
265
+ {
266
+ type: "pointer",
267
+ id: "finger1",
268
+ parameters: { pointerType: "touch" },
269
+ actions: [
270
+ { type: "pointerMove", duration: 0, x, y, origin: "viewport" },
271
+ { type: "pointerDown", button: 0 },
272
+ { type: "pause", duration },
273
+ { type: "pointerUp", button: 0 }
274
+ ]
275
+ }
276
+ ]);
277
+ await this.browser.releaseActions();
278
+ }
279
+ // ─── Swipe gestures ────────────────────────────────────────────────────────
280
+ /** Swipe left within the element bounds. */
281
+ async swipeLeft(options = {}) {
282
+ await this._swipe("left", options);
283
+ }
284
+ /** Swipe right within the element bounds. */
285
+ async swipeRight(options = {}) {
286
+ await this._swipe("right", options);
287
+ }
288
+ /** Swipe up within the element bounds. */
289
+ async swipeUp(options = {}) {
290
+ await this._swipe("up", options);
291
+ }
292
+ /** Swipe down within the element bounds. */
293
+ async swipeDown(options = {}) {
294
+ await this._swipe("down", options);
295
+ }
296
+ // ─── Scroll gestures ───────────────────────────────────────────────────────
297
+ /**
298
+ * Scroll up inside a scrollable container.
299
+ * Slower and longer than swipeUp — use on lists/pages.
300
+ */
301
+ async scrollUp(options = {}) {
302
+ await this._scroll("up", options);
303
+ }
304
+ /**
305
+ * Scroll down inside a scrollable container.
306
+ * Slower and longer than swipeDown — use on lists/pages.
307
+ */
308
+ async scrollDown(options = {}) {
309
+ await this._scroll("down", options);
310
+ }
311
+ /**
312
+ * Check if the element is currently enabled (not greyed out / disabled).
313
+ * Returns false if not present or not enabled.
314
+ * Does NOT auto-wait — for retry behavior, use expect(locator).toBeEnabled().
315
+ */
316
+ async isEnabled() {
317
+ try {
318
+ const wdioSelector = toWdioSelector(parseSelector(this.selector), this.platform);
319
+ const el = this.browser.$(wdioSelector);
320
+ if (!await el.isExisting()) return false;
321
+ return await el.isEnabled();
322
+ } catch {
323
+ return false;
324
+ }
325
+ }
326
+ // ─── Private helpers ───────────────────────────────────────────────────────
327
+ async _swipe(direction, options = {}) {
328
+ const el = await this.waitForElement();
329
+ const duration = options.duration ?? 400;
330
+ const distance = options.distance ?? 0.7;
331
+ if (this.platform === "ios" /* IOS */) {
332
+ const elementId = await el.elementId;
333
+ await this.browser.execute("mobile: swipe", { direction, element: elementId });
334
+ return;
335
+ }
336
+ await this._performSwipeAction(el, direction, duration, distance);
337
+ }
338
+ async _scroll(direction, options = {}) {
339
+ const el = await this.waitForElement();
340
+ const duration = options.duration ?? 800;
341
+ const distance = options.distance ?? 0.8;
342
+ if (this.platform === "ios" /* IOS */) {
343
+ const elementId = await el.elementId;
344
+ await this.browser.execute("mobile: scroll", { direction, element: elementId });
345
+ return;
346
+ }
347
+ await this._performSwipeAction(el, direction, duration, distance);
348
+ }
349
+ async _performSwipeAction(el, direction, duration, distance) {
350
+ const location = await el.getLocation();
351
+ const size = await el.getSize();
352
+ const cx = Math.round(location.x + size.width / 2);
353
+ const cy = Math.round(location.y + size.height / 2);
354
+ const dx = Math.round(size.width * distance * 0.5);
355
+ const dy = Math.round(size.height * distance * 0.5);
356
+ const coords = {
357
+ left: [cx + dx, cy, cx - dx, cy],
358
+ right: [cx - dx, cy, cx + dx, cy],
359
+ up: [cx, cy + dy, cx, cy - dy],
360
+ down: [cx, cy - dy, cx, cy + dy]
361
+ };
362
+ const [startX, startY, endX, endY] = coords[direction];
363
+ await this.browser.performActions([
364
+ {
365
+ type: "pointer",
366
+ id: "finger1",
367
+ parameters: { pointerType: "touch" },
368
+ actions: [
369
+ { type: "pointerMove", duration: 0, x: startX, y: startY, origin: "viewport" },
370
+ { type: "pointerDown", button: 0 },
371
+ { type: "pause", duration: 100 },
372
+ { type: "pointerMove", duration, x: endX, y: endY, origin: "viewport" },
373
+ { type: "pointerUp", button: 0 }
374
+ ]
375
+ }
376
+ ]);
377
+ await this.browser.releaseActions();
378
+ }
379
+ };
380
+
381
+ // src/device/tree.ts
382
+ async function getCleanTree(browser, platform) {
383
+ const raw = await browser.getPageSource();
384
+ return cleanTree(raw, platform);
385
+ }
386
+ function cleanTree(raw, platform) {
387
+ let cleaned = raw;
388
+ if (platform === "android" /* ANDROID */) {
389
+ cleaned = cleaned.replace(/\s+bounds="[^"]*"/g, "");
390
+ cleaned = cleaned.replace(/\s+index="[^"]*"/g, "");
391
+ cleaned = cleaned.replace(/\s+package="[^"]*"/g, "");
392
+ cleaned = cleaned.replace(/\s+(checkable|long-clickable|password|scrollable|selected|checked)="false"/g, "");
393
+ cleaned = cleaned.replace(/\s+focusable="false"/g, "");
394
+ cleaned = cleaned.replace(/\s+focused="false"/g, "");
395
+ cleaned = cleaned.replace(/\s+enabled="true"/g, "");
396
+ cleaned = cleaned.replace(/\s+displayed="true"/g, "");
397
+ cleaned = cleaned.replace(/\s+text=""/g, "");
398
+ cleaned = cleaned.replace(/\s+content-desc=""/g, "");
399
+ cleaned = cleaned.replace(/\s+resource-id=""/g, "");
400
+ } else {
401
+ cleaned = cleaned.replace(/\s+(x|y|width|height)="[^"]*"/g, "");
402
+ cleaned = cleaned.replace(/\s+enabled="true"/g, "");
403
+ cleaned = cleaned.replace(/\s+visible="true"/g, "");
404
+ cleaned = cleaned.replace(/\s+accessible="true"/g, "");
405
+ cleaned = cleaned.replace(/\s+name=""/g, "");
406
+ cleaned = cleaned.replace(/\s+label=""/g, "");
407
+ cleaned = cleaned.replace(/\s+value=""/g, "");
408
+ }
409
+ cleaned = cleaned.replace(/\s{2,}/g, " ");
410
+ cleaned = cleaned.replace(/^<\?xml[^?]*\?>\s*/, "");
411
+ return cleaned.trim();
412
+ }
413
+ var CHARS_PER_TOKEN = 4;
414
+ var DEFAULT_TREE_CHAR_BUDGET = 12e3;
415
+ function fitTreeToBudget(tree, budget = DEFAULT_TREE_CHAR_BUDGET) {
416
+ if (tree.length <= budget) return tree;
417
+ console.warn(
418
+ `[mobwright] accessibility tree (${tree.length} chars) exceeds budget (${budget} chars). Truncating \u2014 AI may miss elements beyond the cutoff.`
419
+ );
420
+ return tree.slice(0, budget) + "\n<!-- ...truncated -->";
421
+ }
422
+ function estimateTokens(text) {
423
+ return Math.ceil(text.length / CHARS_PER_TOKEN);
424
+ }
425
+
426
+ // src/ai/errors.ts
427
+ var AIError = class extends Error {
428
+ constructor(message, cause) {
429
+ super(message);
430
+ this.cause = cause;
431
+ this.name = "AIError";
432
+ }
433
+ cause;
434
+ };
435
+ var AIRequestError = class extends AIError {
436
+ constructor(message, cause) {
437
+ super(message, cause);
438
+ this.name = "AIRequestError";
439
+ }
440
+ };
441
+ var AIResponseError = class extends AIError {
442
+ constructor(message, raw, cause) {
443
+ super(message, cause);
444
+ this.raw = raw;
445
+ this.name = "AIResponseError";
446
+ }
447
+ raw;
448
+ };
449
+ var AIValidationError = class extends AIError {
450
+ constructor(message, cause) {
451
+ super(message, cause);
452
+ this.name = "AIValidationError";
453
+ }
454
+ };
455
+
456
+ // src/device/ai-locator.ts
457
+ var AILocator = class {
458
+ description;
459
+ browser;
460
+ platform;
461
+ provider;
462
+ options;
463
+ /** Cached resolution. Populated on first action. */
464
+ resolved = null;
465
+ /** Cached underlying Locator. Populated on first action. */
466
+ locator = null;
467
+ constructor(browser, platform, provider, description, options = {}) {
468
+ this.browser = browser;
469
+ this.platform = platform;
470
+ this.provider = provider;
471
+ this.description = description;
472
+ this.options = {
473
+ timeout: options.timeout ?? 5e3,
474
+ minConfidence: options.minConfidence ?? 0.5,
475
+ treeCharBudget: options.treeCharBudget ?? 12e3
476
+ };
477
+ }
478
+ /**
479
+ * Resolve the description to a Locator. Called automatically on first action.
480
+ * Exposed for advanced use / debugging.
481
+ */
482
+ async resolve() {
483
+ if (this.locator) return this.locator;
484
+ const tree = await getCleanTree(this.browser, this.platform);
485
+ const bounded = fitTreeToBudget(tree, this.options.treeCharBudget);
486
+ const result = await this.provider.resolveLocator({
487
+ description: this.description,
488
+ accessibilityTree: bounded,
489
+ platform: this.platform
490
+ });
491
+ this.validate(result);
492
+ this.resolved = result;
493
+ const selector = this.buildSelectorString(result);
494
+ this.locator = new Locator(this.browser, this.platform, selector, {
495
+ timeout: this.options.timeout
496
+ });
497
+ console.log(
498
+ `[mobwright.ai] "${this.description}" \u2192 ${selector} (${result.strategy}, confidence ${result.confidence})`
499
+ );
500
+ return this.locator;
501
+ }
502
+ /** Validate the AI's response before trusting it. */
503
+ validate(result) {
504
+ if (result.confidence < this.options.minConfidence) {
505
+ throw new AIValidationError(
506
+ `AI returned low confidence (${result.confidence}) for "${this.description}". Rationale: ${result.rationale ?? "(none)"}
507
+ Try a more specific description, or use device.locator() directly.`
508
+ );
509
+ }
510
+ if (!result.selector || result.selector.trim().length === 0) {
511
+ throw new AIValidationError(
512
+ `AI returned empty selector for "${this.description}".`
513
+ );
514
+ }
515
+ }
516
+ /** Build a mobwright selector string from the AI's strategy + value. */
517
+ buildSelectorString(result) {
518
+ switch (result.strategy) {
519
+ case "accessibility-id":
520
+ return `~${result.selector}`;
521
+ case "id":
522
+ return `#${result.selector}`;
523
+ case "xpath":
524
+ return result.selector;
525
+ case "text":
526
+ return this.platform === "android" /* ANDROID */ ? `//*[@text="${result.selector}"]` : `//*[@label="${result.selector}" or @name="${result.selector}" or @value="${result.selector}"]`;
527
+ default: {
528
+ const _exhaustive = result.strategy;
529
+ throw new AIError(`Unknown selector strategy from AI: ${String(_exhaustive)}`);
530
+ }
531
+ }
532
+ }
533
+ /** Tap the resolved element. Resolves on first call. */
534
+ async tap() {
535
+ const locator = await this.resolve();
536
+ await locator.tap();
537
+ }
538
+ /** Type text into the resolved element. */
539
+ async fill(text) {
540
+ const locator = await this.resolve();
541
+ await locator.fill(text);
542
+ }
543
+ /** Get the visible text of the resolved element. */
544
+ async getText() {
545
+ const locator = await this.resolve();
546
+ return locator.getText();
547
+ }
548
+ /** Check visibility of the resolved element (snapshot, no waiting). */
549
+ async isVisible() {
550
+ try {
551
+ const locator = await this.resolve();
552
+ return locator.isVisible();
553
+ } catch {
554
+ return false;
555
+ }
556
+ }
557
+ /** Wait for the resolved element to be visible. */
558
+ async waitFor(options = {}) {
559
+ const locator = await this.resolve();
560
+ await locator.waitFor(options);
561
+ }
562
+ /** Check if the resolved element is enabled (snapshot, no waiting). */
563
+ async isEnabled() {
564
+ try {
565
+ const locator = await this.resolve();
566
+ return locator.isEnabled();
567
+ } catch {
568
+ return false;
569
+ }
570
+ }
571
+ /**
572
+ * Inspect the resolved selector. Returns null if not yet resolved.
573
+ * Useful for debugging or "save back to a stable selector" workflows.
574
+ */
575
+ getResolved() {
576
+ return this.resolved;
577
+ }
578
+ };
579
+
580
+ // src/device/device.ts
581
+ var Device = class {
582
+ stub = false;
583
+ project;
584
+ platform;
585
+ browser;
586
+ /** Optional AI provider — only set if AI is configured. */
587
+ aiProvider;
588
+ defaultTimeout;
589
+ constructor(browser, project, aiProvider) {
590
+ this.browser = browser;
591
+ this.project = project.name;
592
+ this.platform = project.use.platform;
593
+ this.defaultTimeout = project.use.actionTimeout ?? 5e3;
594
+ this.aiProvider = aiProvider;
595
+ }
596
+ /**
597
+ * Create a lazy reference to a UI element by selector string.
598
+ *
599
+ * Selector syntax:
600
+ * ~foo → accessibility id
601
+ * #foo → resource id (ends-with match; package prefix optional)
602
+ * //... → xpath
603
+ * foo → accessibility id (no prefix)
604
+ */
605
+ locator(selector, options = {}) {
606
+ return new Locator(this.browser, this.platform, selector, {
607
+ timeout: options.timeout ?? this.defaultTimeout
608
+ });
609
+ }
610
+ /**
611
+ * Convenience: locate an element by its visible text or label (exact match).
612
+ */
613
+ getByText(text, options = {}) {
614
+ const xpath = this.platform === "android" /* ANDROID */ ? `//*[@text="${text}"]` : `//*[@label="${text}" or @name="${text}" or @value="${text}"]`;
615
+ return new Locator(this.browser, this.platform, xpath, {
616
+ timeout: options.timeout ?? this.defaultTimeout
617
+ });
618
+ }
619
+ /**
620
+ * Locate an element whose visible text **contains** the substring.
621
+ * Case-insensitive by default.
622
+ *
623
+ * @example
624
+ * device.getByContainingText('get started'); // matches "Get Started", "GET STARTED", etc.
625
+ * device.getByContainingText('Started', { ignoreCase: false }); // case-sensitive substring
626
+ */
627
+ getByContainingText(text, options = {}) {
628
+ const ignoreCase = options.ignoreCase ?? true;
629
+ const lc = "abcdefghijklmnopqrstuvwxyz";
630
+ const uc = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
631
+ let xpath;
632
+ if (this.platform === "android" /* ANDROID */) {
633
+ xpath = ignoreCase ? `//*[contains(translate(@text,"${uc}","${lc}"),"${text.toLowerCase()}")]` : `//*[contains(@text,"${text}")]`;
634
+ } else {
635
+ xpath = ignoreCase ? `//*[contains(translate(@label,"${uc}","${lc}"),"${text.toLowerCase()}") or contains(translate(@name,"${uc}","${lc}"),"${text.toLowerCase()}") or contains(translate(@value,"${uc}","${lc}"),"${text.toLowerCase()}")]` : `//*[contains(@label,"${text}") or contains(@name,"${text}") or contains(@value,"${text}")]`;
636
+ }
637
+ return new Locator(this.browser, this.platform, xpath, {
638
+ timeout: options.timeout ?? this.defaultTimeout
639
+ });
640
+ }
641
+ /**
642
+ * Take a screenshot. Returns raw PNG bytes.
643
+ */
644
+ async screenshot() {
645
+ const base64 = await this.browser.takeScreenshot();
646
+ return Buffer.from(base64, "base64");
647
+ }
648
+ /**
649
+ * Get the current accessibility tree as an XML string.
650
+ * Used later by AI locator resolution.
651
+ */
652
+ async getPageSource() {
653
+ return await this.browser.getPageSource();
654
+ }
655
+ /**
656
+ * Create a natural-language locator. Resolves on first action via AI.
657
+ *
658
+ * Requires AI to be configured (MOBWRIGHT_AI_PROVIDER + MOBWRIGHT_AI_API_KEY).
659
+ * Throws AIError immediately if AI is not configured.
660
+ *
661
+ * @example
662
+ * await device.ai('the blue Continue button at the bottom').tap();
663
+ * await device.ai('the email input field').fill('me@example.com');
664
+ */
665
+ ai(description, options = {}) {
666
+ if (!this.aiProvider) {
667
+ throw new AIError(
668
+ "device.ai() requires an AI provider. Set MOBWRIGHT_AI_PROVIDER and MOBWRIGHT_AI_API_KEY in your environment."
669
+ );
670
+ }
671
+ return new AILocator(this.browser, this.platform, this.aiProvider, description, {
672
+ timeout: options.timeout ?? this.defaultTimeout,
673
+ minConfidence: options.minConfidence,
674
+ treeCharBudget: options.treeCharBudget
675
+ });
676
+ }
677
+ /**
678
+ * Get cumulative token usage from the AI provider (if it tracks tokens).
679
+ * Returns null if AI is not configured or if the provider doesn't support token tracking.
680
+ */
681
+ getAITokenUsage() {
682
+ if (!this.aiProvider || !("getTokenUsage" in this.aiProvider)) {
683
+ return null;
684
+ }
685
+ return this.aiProvider.getTokenUsage();
686
+ }
687
+ };
688
+
689
+ // src/ai/openai.ts
690
+ import OpenAI from "openai";
691
+
692
+ // src/ai/prompts.ts
693
+ var LOCATOR_SYSTEM_PROMPT = `
694
+ You are a mobile UI testing assistant. Given an accessibility tree from
695
+ a mobile app and a natural-language description of a target element,
696
+ return a concrete selector to find that element.
697
+
698
+ Your response MUST be valid JSON with this shape:
699
+ {
700
+ "selector": "string \u2014 the locator value",
701
+ "strategy": "accessibility-id" | "id" | "xpath" | "text",
702
+ "confidence": 0.0\u20131.0,
703
+ "rationale": "string \u2014 brief explanation"
704
+ }
705
+
706
+ Selector formats by strategy:
707
+ - accessibility-id: just the value (no prefix). Maps to content-desc (Android) or name (iOS).
708
+ - id: just the value (no prefix). Maps to resource-id suffix (Android) or name (iOS).
709
+ - xpath: full xpath expression starting with //
710
+ - text: just the visible text value
711
+
712
+ Prefer accessibility-id > id > text > xpath, in that order.
713
+ Lower confidence (<0.7) if you are guessing.
714
+ Return ONLY the JSON, no markdown fences, no extra prose.
715
+ `.trim();
716
+ function buildUserPrompt(input) {
717
+ return [
718
+ `Platform: ${input.platform}`,
719
+ `Target element: ${input.description}`,
720
+ "",
721
+ "Accessibility tree:",
722
+ input.accessibilityTree
723
+ ].join("\n");
724
+ }
725
+
726
+ // src/ai/openai.ts
727
+ var OpenAICompatibleProvider = class {
728
+ name;
729
+ client;
730
+ model;
731
+ constructor(config) {
732
+ this.name = config.name ?? "openai";
733
+ this.model = config.model;
734
+ this.client = new OpenAI({
735
+ apiKey: config.apiKey,
736
+ baseURL: config.baseURL,
737
+ timeout: config.timeout ?? 3e4
738
+ });
739
+ }
740
+ async resolveLocator(input) {
741
+ let response;
742
+ try {
743
+ response = await this.client.chat.completions.create({
744
+ model: this.model,
745
+ messages: [
746
+ { role: "system", content: LOCATOR_SYSTEM_PROMPT },
747
+ { role: "user", content: buildUserPrompt(input) }
748
+ ],
749
+ // Many recent OpenAI/DeepSeek models support JSON mode
750
+ response_format: { type: "json_object" },
751
+ temperature: 0
752
+ });
753
+ } catch (err) {
754
+ throw new AIRequestError(`${this.name} request failed`, err);
755
+ }
756
+ const raw = response.choices[0]?.message?.content;
757
+ if (!raw) {
758
+ throw new AIResponseError(`${this.name} returned empty content`);
759
+ }
760
+ return parseLocatorResponse(raw, this.name);
761
+ }
762
+ };
763
+ function parseLocatorResponse(raw, providerName) {
764
+ let parsed;
765
+ try {
766
+ const cleaned = raw.replace(/^```(?:json)?\s*|\s*```$/g, "").trim();
767
+ parsed = JSON.parse(cleaned);
768
+ } catch (err) {
769
+ throw new AIResponseError(`${providerName} returned non-JSON`, raw, err);
770
+ }
771
+ if (!parsed || typeof parsed !== "object") {
772
+ throw new AIResponseError(`${providerName} returned non-object`, raw);
773
+ }
774
+ const obj = parsed;
775
+ const selector = obj.selector;
776
+ const strategy = obj.strategy;
777
+ const confidence = obj.confidence;
778
+ if (typeof selector !== "string" || selector.length === 0) {
779
+ throw new AIResponseError(`${providerName}: missing or invalid "selector"`, raw);
780
+ }
781
+ if (!isValidStrategy(strategy)) {
782
+ throw new AIResponseError(
783
+ `${providerName}: invalid "strategy" \u2014 got ${String(strategy)}`,
784
+ raw
785
+ );
786
+ }
787
+ if (typeof confidence !== "number" || confidence < 0 || confidence > 1) {
788
+ throw new AIResponseError(
789
+ `${providerName}: invalid "confidence" \u2014 got ${String(confidence)}`,
790
+ raw
791
+ );
792
+ }
793
+ const rationale = typeof obj.rationale === "string" ? obj.rationale : void 0;
794
+ return { selector, strategy, confidence, rationale };
795
+ }
796
+ function isValidStrategy(value) {
797
+ return value === "accessibility-id" || value === "id" || value === "xpath" || value === "text";
798
+ }
799
+
800
+ // src/ai/deepseek.ts
801
+ var DeepSeekProvider = class extends OpenAICompatibleProvider {
802
+ constructor(config) {
803
+ super({
804
+ name: "deepseek",
805
+ apiKey: config.apiKey,
806
+ model: config.model || "deepseek-chat",
807
+ baseURL: config.baseURL ?? "https://api.deepseek.com",
808
+ timeout: config.timeout
809
+ });
810
+ }
811
+ };
812
+
813
+ // src/ai/anthropic.ts
814
+ import Anthropic from "@anthropic-ai/sdk";
815
+ var AnthropicProvider = class {
816
+ name = "anthropic";
817
+ client;
818
+ model;
819
+ totalInputTokens = 0;
820
+ totalOutputTokens = 0;
821
+ constructor(config) {
822
+ this.model = config.model;
823
+ this.client = new Anthropic({
824
+ apiKey: config.apiKey,
825
+ baseURL: config.baseURL,
826
+ timeout: config.timeout ?? 3e4
827
+ });
828
+ }
829
+ async resolveLocator(input) {
830
+ let response;
831
+ try {
832
+ response = await this.client.messages.create({
833
+ model: this.model,
834
+ max_tokens: 1024,
835
+ system: LOCATOR_SYSTEM_PROMPT,
836
+ messages: [{ role: "user", content: buildUserPrompt(input) }],
837
+ temperature: 0
838
+ });
839
+ } catch (err) {
840
+ throw new AIRequestError(`${this.name} request failed`, err);
841
+ }
842
+ const text = response.content.filter((block) => block.type === "text").map((block) => block.text).join("").trim();
843
+ if (!text) {
844
+ throw new AIResponseError(`${this.name} returned no text content`);
845
+ }
846
+ const usage = response.usage;
847
+ if (!usage) {
848
+ return parseLocatorResponse(text, this.name);
849
+ }
850
+ this.totalInputTokens += usage.input_tokens;
851
+ this.totalOutputTokens += usage.output_tokens;
852
+ const totalTokens = usage.input_tokens + usage.output_tokens;
853
+ console.log(
854
+ `[${this.name}] tokens: ${usage.input_tokens} in + ${usage.output_tokens} out = ${totalTokens} total`
855
+ );
856
+ return parseLocatorResponse(text, this.name);
857
+ }
858
+ /** Get cumulative token usage across all requests. */
859
+ getTokenUsage() {
860
+ return {
861
+ inputTokens: this.totalInputTokens,
862
+ outputTokens: this.totalOutputTokens,
863
+ totalTokens: this.totalInputTokens + this.totalOutputTokens
864
+ };
865
+ }
866
+ };
867
+
868
+ // src/ai/factory.ts
869
+ var DEFAULT_MODELS = {
870
+ anthropic: "claude-haiku-4-5-20251001",
871
+ openai: "gpt-4o-mini",
872
+ deepseek: "deepseek-chat"
873
+ };
874
+ function createProvider(config) {
875
+ const model = config.model || DEFAULT_MODELS[config.provider];
876
+ switch (config.provider) {
877
+ case "anthropic":
878
+ return new AnthropicProvider({
879
+ apiKey: config.apiKey,
880
+ model,
881
+ baseURL: config.baseURL,
882
+ timeout: config.timeout
883
+ });
884
+ case "openai":
885
+ return new OpenAICompatibleProvider({
886
+ apiKey: config.apiKey,
887
+ model,
888
+ baseURL: config.baseURL,
889
+ timeout: config.timeout,
890
+ name: "openai"
891
+ });
892
+ case "deepseek":
893
+ return new DeepSeekProvider({
894
+ apiKey: config.apiKey,
895
+ model,
896
+ baseURL: config.baseURL,
897
+ timeout: config.timeout
898
+ });
899
+ default: {
900
+ const _exhaustive = config.provider;
901
+ throw new Error(`Unknown AI provider: ${String(_exhaustive)}`);
902
+ }
903
+ }
904
+ }
905
+
906
+ // src/appium/ios-utils.ts
907
+ import { execFile } from "child_process";
908
+ import { promisify } from "util";
909
+ var exec = promisify(execFile);
910
+ async function isIOSAppInstalled(bundleId, udid) {
911
+ const target = udid ?? "booted";
912
+ try {
913
+ const { stdout } = await exec("xcrun", [
914
+ "simctl",
915
+ "get_app_container",
916
+ target,
917
+ bundleId,
918
+ "app"
919
+ ]);
920
+ return stdout.trim().length > 0;
921
+ } catch {
922
+ return false;
923
+ }
924
+ }
925
+
926
+ // src/fixtures/device.ts
927
+ function resolveProjectConfig(projectName) {
928
+ if (projectName === "android") {
929
+ const appPath = process.env.MOBWRIGHT_APP_PATH;
930
+ if (!appPath) {
931
+ throw new Error(
932
+ "MOBWRIGHT_APP_PATH is not set. Point it at your .apk:\n MOBWRIGHT_APP_PATH=/absolute/path/to/app.apk"
933
+ );
934
+ }
935
+ const avd = process.env.MOBWRIGHT_ANDROID_AVD;
936
+ if (!avd) {
937
+ throw new Error(
938
+ "MOBWRIGHT_ANDROID_AVD is not set. Set your emulator AVD name:\n MOBWRIGHT_ANDROID_AVD=Pixel_9_Pro_API_Baklava"
939
+ );
940
+ }
941
+ return {
942
+ name: "android",
943
+ use: {
944
+ platform: "android" /* ANDROID */,
945
+ device: { provider: "emulator", name: avd },
946
+ buildPath: appPath,
947
+ appActivity: process.env.MOBWRIGHT_ANDROID_APP_ACTIVITY,
948
+ appPackage: process.env.MOBWRIGHT_ANDROID_APP_PACKAGE
949
+ }
950
+ };
951
+ }
952
+ if (projectName === "ios") {
953
+ const sim = process.env.MOBWRIGHT_IOS_DEVICE ?? "iPhone 15";
954
+ const bundleId = process.env.MOBWRIGHT_IOS_BUNDLE_ID;
955
+ const appPath = process.env.MOBWRIGHT_IOS_APP_PATH;
956
+ if (!bundleId && !appPath) {
957
+ throw new Error(
958
+ "iOS needs MOBWRIGHT_IOS_BUNDLE_ID, MOBWRIGHT_IOS_APP_PATH, or both:\n MOBWRIGHT_IOS_BUNDLE_ID=com.voila.id\n MOBWRIGHT_IOS_APP_PATH=/absolute/path/to/Voila.app"
959
+ );
960
+ }
961
+ return {
962
+ name: "ios",
963
+ use: {
964
+ platform: "ios" /* IOS */,
965
+ device: { provider: "simulator", name: sim },
966
+ buildPath: appPath ?? "",
967
+ bundleId,
968
+ platformVersion: process.env.MOBWRIGHT_IOS_PLATFORM_VERSION,
969
+ udid: process.env.MOBWRIGHT_IOS_UDID
970
+ }
971
+ };
972
+ }
973
+ throw new Error(`Unknown mobwright project: ${projectName}. Use 'android' or 'ios'.`);
974
+ }
975
+ async function reconcileIOSInstall(project) {
976
+ if (project.use.platform !== "ios" /* IOS */) return project;
977
+ if (!project.use.bundleId) return project;
978
+ const installed = await isIOSAppInstalled(project.use.bundleId, project.use.udid);
979
+ if (installed) {
980
+ console.log(
981
+ `[mobwright] ${project.use.bundleId} already installed \u2014 launching by bundleId`
982
+ );
983
+ return {
984
+ ...project,
985
+ use: { ...project.use, buildPath: "" }
986
+ };
987
+ }
988
+ if (!project.use.buildPath) {
989
+ throw new Error(
990
+ `iOS app ${project.use.bundleId} is not installed on the simulator and MOBWRIGHT_IOS_APP_PATH is not set. Either install the app manually or provide MOBWRIGHT_IOS_APP_PATH.`
991
+ );
992
+ }
993
+ console.log(
994
+ `[mobwright] ${project.use.bundleId} not installed \u2014 installing from ${project.use.buildPath}`
995
+ );
996
+ return project;
997
+ }
998
+ var test = base.extend({
999
+ // eslint-disable-next-line no-empty-pattern
1000
+ device: async ({}, use, testInfo) => {
1001
+ let projectConfig = resolveProjectConfig(testInfo.project.name);
1002
+ projectConfig = await reconcileIOSInstall(projectConfig);
1003
+ const aiConfig = resolveAIConfig();
1004
+ const aiProvider = aiConfig ? createProvider(aiConfig) : void 0;
1005
+ const browser = await createSession(projectConfig);
1006
+ const device = new Device(browser, projectConfig, aiProvider);
1007
+ try {
1008
+ await use(device);
1009
+ } finally {
1010
+ await destroySession(browser);
1011
+ }
1012
+ }
1013
+ });
1014
+ function resolveAIConfig() {
1015
+ const provider = process.env.MOBWRIGHT_AI_PROVIDER;
1016
+ const apiKey = process.env.MOBWRIGHT_AI_API_KEY;
1017
+ if (!provider || !apiKey) return void 0;
1018
+ if (provider !== "anthropic" && provider !== "openai" && provider !== "deepseek") {
1019
+ throw new Error(
1020
+ `MOBWRIGHT_AI_PROVIDER must be one of 'anthropic', 'openai', 'deepseek' \u2014 got '${provider}'`
1021
+ );
1022
+ }
1023
+ return {
1024
+ provider,
1025
+ model: process.env.MOBWRIGHT_AI_MODEL ?? "",
1026
+ apiKey,
1027
+ baseURL: process.env.MOBWRIGHT_AI_BASE_URL
1028
+ };
1029
+ }
1030
+
1031
+ // src/expect/matchers.ts
1032
+ import { expect as baseExpect } from "@playwright/test";
1033
+ var DEFAULT_TIMEOUT = 5e3;
1034
+ var POLL_INTERVAL = 100;
1035
+ async function pollUntilTrue(fn, timeout) {
1036
+ const deadline = Date.now() + timeout;
1037
+ while (Date.now() < deadline) {
1038
+ try {
1039
+ if (await fn()) return true;
1040
+ } catch {
1041
+ }
1042
+ await new Promise((r) => setTimeout(r, POLL_INTERVAL));
1043
+ }
1044
+ try {
1045
+ return await fn();
1046
+ } catch {
1047
+ return false;
1048
+ }
1049
+ }
1050
+ function matchText(actual, expected) {
1051
+ if (expected instanceof RegExp) return expected.test(actual);
1052
+ return actual === expected;
1053
+ }
1054
+ function containsText(actual, expected, options = {}) {
1055
+ if (expected instanceof RegExp) return expected.test(actual);
1056
+ const ignoreCase = options.ignoreCase ?? true;
1057
+ if (ignoreCase) {
1058
+ return actual.toLowerCase().includes(expected.toLowerCase());
1059
+ }
1060
+ return actual.includes(expected);
1061
+ }
1062
+ var expect = baseExpect.extend({
1063
+ /**
1064
+ * Assert the element is currently visible (present + displayed).
1065
+ * Auto-retries until visible or timeout.
1066
+ */
1067
+ async toBeVisible(received, options = {}) {
1068
+ const timeout = options.timeout ?? DEFAULT_TIMEOUT;
1069
+ const pass = await pollUntilTrue(() => received.isVisible(), timeout);
1070
+ return {
1071
+ pass,
1072
+ name: "toBeVisible",
1073
+ message: () => pass ? `Expected locator "${received.selector}" NOT to be visible, but it was.` : `Expected locator "${received.selector}" to be visible within ${timeout}ms, but it was not.`
1074
+ };
1075
+ },
1076
+ /**
1077
+ * Assert the element's visible text matches.
1078
+ * Pass a string for exact match, or a RegExp for pattern match.
1079
+ * Auto-retries until match or timeout.
1080
+ */
1081
+ async toHaveText(received, expected, options = {}) {
1082
+ const timeout = options.timeout ?? DEFAULT_TIMEOUT;
1083
+ let lastText = "";
1084
+ const pass = await pollUntilTrue(async () => {
1085
+ lastText = await received.getText();
1086
+ return matchText(lastText, expected);
1087
+ }, timeout);
1088
+ return {
1089
+ pass,
1090
+ name: "toHaveText",
1091
+ expected,
1092
+ actual: lastText,
1093
+ message: () => pass ? `Expected locator "${received.selector}" NOT to have text ${JSON.stringify(expected)}, but it did.` : `Expected locator "${received.selector}" to have text ${JSON.stringify(expected)} within ${timeout}ms, but got ${JSON.stringify(lastText)}.`
1094
+ };
1095
+ },
1096
+ /**
1097
+ * Assert the element's visible text **contains** the expected substring.
1098
+ * Case-insensitive by default. Pass `ignoreCase: false` for exact-case check.
1099
+ * Also accepts a RegExp.
1100
+ * Auto-retries until match or timeout.
1101
+ */
1102
+ async toContainText(received, expected, options = {}) {
1103
+ const timeout = options.timeout ?? DEFAULT_TIMEOUT;
1104
+ let lastText = "";
1105
+ const pass = await pollUntilTrue(async () => {
1106
+ lastText = await received.getText();
1107
+ return containsText(lastText, expected, { ignoreCase: options.ignoreCase });
1108
+ }, timeout);
1109
+ return {
1110
+ pass,
1111
+ name: "toContainText",
1112
+ expected,
1113
+ actual: lastText,
1114
+ message: () => pass ? `Expected locator "${received.selector}" NOT to contain text ${JSON.stringify(expected)}, but it did (got ${JSON.stringify(lastText)}).` : `Expected locator "${received.selector}" to contain text ${JSON.stringify(expected)} within ${timeout}ms, but got ${JSON.stringify(lastText)}.`
1115
+ };
1116
+ },
1117
+ /**
1118
+ * Assert the element is enabled (interactable).
1119
+ * Auto-retries until enabled or timeout.
1120
+ */
1121
+ async toBeEnabled(received, options = {}) {
1122
+ const timeout = options.timeout ?? DEFAULT_TIMEOUT;
1123
+ if (!received.isEnabled) {
1124
+ throw new Error(
1125
+ `Locator "${received.selector}" does not support isEnabled(). Make sure you're using the latest mobwright version.`
1126
+ );
1127
+ }
1128
+ const pass = await pollUntilTrue(() => received.isEnabled(), timeout);
1129
+ return {
1130
+ pass,
1131
+ name: "toBeEnabled",
1132
+ message: () => pass ? `Expected locator "${received.selector}" NOT to be enabled, but it was.` : `Expected locator "${received.selector}" to be enabled within ${timeout}ms, but it was not.`
1133
+ };
1134
+ }
1135
+ });
1136
+
1137
+ // src/config.ts
1138
+ function defineConfig(config) {
1139
+ return config;
1140
+ }
1141
+ export {
1142
+ AIError,
1143
+ AILocator,
1144
+ AIRequestError,
1145
+ AIResponseError,
1146
+ AIValidationError,
1147
+ AnthropicProvider,
1148
+ DEFAULT_TREE_CHAR_BUDGET,
1149
+ DeepSeekProvider,
1150
+ Device,
1151
+ Locator,
1152
+ OpenAICompatibleProvider,
1153
+ Platform,
1154
+ cleanTree,
1155
+ createProvider,
1156
+ defineConfig,
1157
+ estimateTokens,
1158
+ expect,
1159
+ fitTreeToBudget,
1160
+ getCleanTree,
1161
+ test
1162
+ };
1163
+ //# sourceMappingURL=index.js.map