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