mobile-debug-mcp 0.21.0 → 0.21.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/interact/android.js +0 -27
- package/dist/interact/index.js +23 -38
- package/dist/interact/ios.js +0 -26
- package/dist/server.js +5 -33
- package/dist/utils/resolve-device.js +5 -0
- package/docs/CHANGELOG.md +5 -2
- package/docs/tools/interact.md +7 -27
- package/package.json +2 -2
- package/src/interact/android.ts +1 -32
- package/src/interact/index.ts +38 -46
- package/src/interact/ios.ts +1 -31
- package/src/server.ts +6 -33
- package/src/utils/resolve-device.ts +6 -0
- package/test/interact/device/run-real-test.ts +3 -19
- package/test/interact/unit/{observe_until.test.ts → wait_for_ui.test.ts} +6 -6
- package/test/observe/device/wait_for_element_real.ts +3 -80
- package/test/observe/unit/wait_for_element_mock.ts +2 -104
- package/test/observe/unit/{observe_until_edge_cases.test.ts → wait_for_ui_edge_cases.test.ts} +5 -5
- package/test/observe/unit/{observe_until_stability.test.ts → wait_for_ui_stability.test.ts} +3 -3
- package/test/unit/index.ts +27 -15
package/dist/interact/android.js
CHANGED
|
@@ -3,33 +3,6 @@ import { AndroidObserve } from "../observe/index.js";
|
|
|
3
3
|
import { scrollToElementShared } from "../utils/ui/index.js";
|
|
4
4
|
export class AndroidInteract {
|
|
5
5
|
observe = new AndroidObserve();
|
|
6
|
-
async waitForElement(text, timeout, deviceId) {
|
|
7
|
-
const metadata = await getAndroidDeviceMetadata("", deviceId);
|
|
8
|
-
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
|
|
9
|
-
const startTime = Date.now();
|
|
10
|
-
while (Date.now() - startTime < timeout) {
|
|
11
|
-
try {
|
|
12
|
-
const tree = await this.observe.getUITree(deviceId);
|
|
13
|
-
if (tree.error) {
|
|
14
|
-
return { device: deviceInfo, found: false, error: tree.error };
|
|
15
|
-
}
|
|
16
|
-
const element = tree.elements.find(e => e.text === text);
|
|
17
|
-
if (element) {
|
|
18
|
-
return { device: deviceInfo, found: true, element };
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
catch (e) {
|
|
22
|
-
// Ignore errors during polling and retry
|
|
23
|
-
console.error("Error polling UI tree:", e);
|
|
24
|
-
}
|
|
25
|
-
const elapsed = Date.now() - startTime;
|
|
26
|
-
const remaining = timeout - elapsed;
|
|
27
|
-
if (remaining <= 0)
|
|
28
|
-
break;
|
|
29
|
-
await new Promise(resolve => setTimeout(resolve, Math.min(500, remaining)));
|
|
30
|
-
}
|
|
31
|
-
return { device: deviceInfo, found: false };
|
|
32
|
-
}
|
|
33
6
|
async tap(x, y, deviceId) {
|
|
34
7
|
const metadata = await getAndroidDeviceMetadata("", deviceId);
|
|
35
8
|
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
|
package/dist/interact/index.js
CHANGED
|
@@ -10,11 +10,6 @@ export class ToolsInteract {
|
|
|
10
10
|
const interact = effectivePlatform === 'android' ? new AndroidInteract() : new iOSInteract();
|
|
11
11
|
return { interact: interact, resolved, platform: effectivePlatform };
|
|
12
12
|
}
|
|
13
|
-
static async waitForElementHandler({ platform, text, timeout, deviceId }) {
|
|
14
|
-
const effectiveTimeout = timeout ?? 10000;
|
|
15
|
-
const { interact, resolved } = await ToolsInteract.getInteractionService(platform, deviceId);
|
|
16
|
-
return await interact.waitForElement(text, effectiveTimeout, resolved.id);
|
|
17
|
-
}
|
|
18
13
|
static async tapHandler({ platform, x, y, deviceId }) {
|
|
19
14
|
const { interact, resolved } = await ToolsInteract.getInteractionService(platform, deviceId);
|
|
20
15
|
return await interact.tap(x, y, resolved.id);
|
|
@@ -220,6 +215,10 @@ export class ToolsInteract {
|
|
|
220
215
|
const scoreVal = Math.min(1, Number(bestScore.toFixed(3)));
|
|
221
216
|
return { found: true, element: outEl, score: scoreVal, confidence: scoreVal };
|
|
222
217
|
}
|
|
218
|
+
static async waitForUIHandler({ type = 'ui', query, timeoutMs = 30000, pollIntervalMs = 300, includeSnapshotOnFailure = true, match = 'present', stability_ms = 700, observationDelayMs = 0, platform, deviceId }) {
|
|
219
|
+
// Backwards-compatible wrapper that delegates to the core waitForUICore implementation
|
|
220
|
+
return await ToolsInteract.waitForUICore({ type, query, timeoutMs, pollIntervalMs, includeSnapshotOnFailure, match, stability_ms, observationDelayMs, platform, deviceId });
|
|
221
|
+
}
|
|
223
222
|
static async waitForScreenChangeHandler({ platform, previousFingerprint, timeoutMs = 5000, pollIntervalMs = 300, deviceId }) {
|
|
224
223
|
const start = Date.now();
|
|
225
224
|
let lastFingerprint = null;
|
|
@@ -258,7 +257,7 @@ export class ToolsInteract {
|
|
|
258
257
|
}
|
|
259
258
|
return { success: false, reason: 'timeout', lastFingerprint, elapsedMs: Date.now() - start };
|
|
260
259
|
}
|
|
261
|
-
static async
|
|
260
|
+
static async waitForUICore({ type = 'ui', query, timeoutMs = 30000, pollIntervalMs = 300, includeSnapshotOnFailure = true, match = 'present', stability_ms = 700, observationDelayMs = 0, platform, deviceId }) {
|
|
262
261
|
const start = Date.now();
|
|
263
262
|
const deadline = start + (timeoutMs || 0);
|
|
264
263
|
const q = (query === null || query === undefined) ? '' : String(query);
|
|
@@ -281,7 +280,7 @@ export class ToolsInteract {
|
|
|
281
280
|
}
|
|
282
281
|
catch (err) {
|
|
283
282
|
try {
|
|
284
|
-
console.warn('
|
|
283
|
+
console.warn('waitForUI: failed to get baseline data (non-fatal):', err instanceof Error ? err.message : String(err));
|
|
285
284
|
}
|
|
286
285
|
catch { }
|
|
287
286
|
}
|
|
@@ -292,7 +291,7 @@ export class ToolsInteract {
|
|
|
292
291
|
// Optional initial observation delay requested by caller
|
|
293
292
|
if (typeof observationDelayMs === 'number' && observationDelayMs > 0) {
|
|
294
293
|
try {
|
|
295
|
-
console.log(`
|
|
294
|
+
console.log(`waitForUI: delaying observation for ${observationDelayMs}ms`);
|
|
296
295
|
}
|
|
297
296
|
catch { }
|
|
298
297
|
await sleep(observationDelayMs);
|
|
@@ -309,29 +308,11 @@ export class ToolsInteract {
|
|
|
309
308
|
// Evaluate condition per type
|
|
310
309
|
if (type === 'ui') {
|
|
311
310
|
try {
|
|
312
|
-
//
|
|
311
|
+
// Prefer using the public findElementHandler which tests can override. This avoids relying
|
|
312
|
+
// on resolveObserve/getUITree for unit tests which may not have devices available.
|
|
313
313
|
try {
|
|
314
|
-
|
|
315
|
-
const
|
|
316
|
-
const tree = await withTimeout(ToolsObserve.getUITreeHandler({ platform, deviceId }), Math.min(pollInterval, 500));
|
|
317
|
-
const elems = Array.isArray(tree && tree.elements) ? tree.elements : [];
|
|
318
|
-
const qnorm = q.toLowerCase();
|
|
319
|
-
let matched = null;
|
|
320
|
-
for (const el of elems) {
|
|
321
|
-
try {
|
|
322
|
-
const txt = ((el && (el.text || el.label || el.value || el.contentDescription || el.accessibilityLabel)) || '');
|
|
323
|
-
if (!txt)
|
|
324
|
-
continue;
|
|
325
|
-
if (String(txt).toLowerCase().includes(qnorm)) {
|
|
326
|
-
matched = el;
|
|
327
|
-
break;
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
catch {
|
|
331
|
-
continue;
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
const isPresent = !!matched;
|
|
314
|
+
const findRes = await ToolsInteract.findElementHandler({ query: q, exact: false, timeoutMs: Math.min(500, pollInterval), platform, deviceId });
|
|
315
|
+
const isPresent = !!(findRes && findRes.found);
|
|
335
316
|
const conditionTrue = (match === 'present') ? isPresent : !isPresent;
|
|
336
317
|
if (conditionTrue) {
|
|
337
318
|
if (matchedAt === null)
|
|
@@ -339,8 +320,8 @@ export class ToolsInteract {
|
|
|
339
320
|
stableDuration = Date.now() - matchedAt;
|
|
340
321
|
lastObservedState = true;
|
|
341
322
|
if (stableDuration >= stability_ms) {
|
|
342
|
-
matchSource = 'ui-
|
|
343
|
-
const element = isPresent ?
|
|
323
|
+
matchSource = 'ui-find';
|
|
324
|
+
const element = isPresent ? findRes.element : null;
|
|
344
325
|
const now2 = Date.now();
|
|
345
326
|
return { success: true, condition: match, query: q, poll_count: pollCount, duration_ms: now2 - start, stable_duration_ms: stableDuration, matchedElement: element, matchSource, timestamp: now2, type: 'ui', observed_state: lastObservedState ?? null };
|
|
346
327
|
}
|
|
@@ -352,11 +333,11 @@ export class ToolsInteract {
|
|
|
352
333
|
}
|
|
353
334
|
}
|
|
354
335
|
catch (err) {
|
|
355
|
-
console.error('
|
|
336
|
+
console.error('waitForUI(ui) find error:', err);
|
|
356
337
|
}
|
|
357
338
|
}
|
|
358
339
|
catch (err) {
|
|
359
|
-
console.error('
|
|
340
|
+
console.error('waitForUI(ui) outer error:', err);
|
|
360
341
|
}
|
|
361
342
|
}
|
|
362
343
|
else if (type === 'log') {
|
|
@@ -387,7 +368,7 @@ export class ToolsInteract {
|
|
|
387
368
|
}
|
|
388
369
|
}
|
|
389
370
|
catch (err) {
|
|
390
|
-
console.error('
|
|
371
|
+
console.error('waitForUI(log) error:', err);
|
|
391
372
|
}
|
|
392
373
|
}
|
|
393
374
|
else if (type === 'screen') {
|
|
@@ -415,7 +396,7 @@ export class ToolsInteract {
|
|
|
415
396
|
}
|
|
416
397
|
}
|
|
417
398
|
catch (err) {
|
|
418
|
-
console.error('
|
|
399
|
+
console.error('waitForUI(screen) error:', err);
|
|
419
400
|
}
|
|
420
401
|
}
|
|
421
402
|
else if (type === 'idle') {
|
|
@@ -439,7 +420,7 @@ export class ToolsInteract {
|
|
|
439
420
|
}
|
|
440
421
|
}
|
|
441
422
|
catch (err) {
|
|
442
|
-
console.error('
|
|
423
|
+
console.error('waitForUI(idle) error:', err);
|
|
443
424
|
}
|
|
444
425
|
}
|
|
445
426
|
// Respect poll interval and avoid tight loop
|
|
@@ -449,7 +430,11 @@ export class ToolsInteract {
|
|
|
449
430
|
let snapshot = null;
|
|
450
431
|
if (includeSnapshotOnFailure) {
|
|
451
432
|
try {
|
|
452
|
-
|
|
433
|
+
// Use dynamic import to avoid circular-initialization issues where the ToolsObserve
|
|
434
|
+
// binding captured earlier may not reflect test-time overrides. Importing at call
|
|
435
|
+
// time ensures the latest exported ToolsObserve object is used.
|
|
436
|
+
const Obs = await import('../observe/index.js');
|
|
437
|
+
snapshot = await Obs.ToolsObserve.captureDebugSnapshotHandler({ reason: `wait_for_ui timeout for ${type}`, includeLogs: true, platform, deviceId });
|
|
453
438
|
}
|
|
454
439
|
catch (err) {
|
|
455
440
|
snapshot = { error: err instanceof Error ? err.message : String(err) };
|
package/dist/interact/ios.js
CHANGED
|
@@ -4,32 +4,6 @@ import { iOSObserve } from "../observe/index.js";
|
|
|
4
4
|
import { scrollToElementShared } from "../utils/ui/index.js";
|
|
5
5
|
export class iOSInteract {
|
|
6
6
|
observe = new iOSObserve();
|
|
7
|
-
async waitForElement(text, timeout, deviceId = "booted") {
|
|
8
|
-
const device = await getIOSDeviceMetadata(deviceId);
|
|
9
|
-
const startTime = Date.now();
|
|
10
|
-
while (Date.now() - startTime < timeout) {
|
|
11
|
-
try {
|
|
12
|
-
const tree = await this.observe.getUITree(deviceId);
|
|
13
|
-
if (tree.error) {
|
|
14
|
-
return { device, found: false, error: tree.error };
|
|
15
|
-
}
|
|
16
|
-
const element = tree.elements.find(e => e.text === text);
|
|
17
|
-
if (element) {
|
|
18
|
-
return { device, found: true, element };
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
catch (e) {
|
|
22
|
-
// Ignore errors during polling and retry
|
|
23
|
-
console.error("Error polling UI tree:", e);
|
|
24
|
-
}
|
|
25
|
-
const elapsed = Date.now() - startTime;
|
|
26
|
-
const remaining = timeout - elapsed;
|
|
27
|
-
if (remaining <= 0)
|
|
28
|
-
break;
|
|
29
|
-
await new Promise(resolve => setTimeout(resolve, Math.min(500, remaining)));
|
|
30
|
-
}
|
|
31
|
-
return { device, found: false };
|
|
32
|
-
}
|
|
33
7
|
async tap(x, y, deviceId = "booted") {
|
|
34
8
|
const device = await getIOSDeviceMetadata(deviceId);
|
|
35
9
|
// Use shared helper to detect idb
|
package/dist/server.js
CHANGED
|
@@ -315,8 +315,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
315
315
|
}
|
|
316
316
|
},
|
|
317
317
|
{
|
|
318
|
-
name: "
|
|
319
|
-
description: "Wait for a UI condition
|
|
318
|
+
name: "wait_for_ui",
|
|
319
|
+
description: "Wait for a UI/log/screen/idle condition with a stability window before returning success.",
|
|
320
320
|
inputSchema: {
|
|
321
321
|
type: "object",
|
|
322
322
|
properties: {
|
|
@@ -332,34 +332,6 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
332
332
|
}
|
|
333
333
|
}
|
|
334
334
|
},
|
|
335
|
-
{
|
|
336
|
-
name: "wait_for_element",
|
|
337
|
-
description: "Wait until a UI element with matching text appears on screen or timeout is reached.",
|
|
338
|
-
inputSchema: {
|
|
339
|
-
type: "object",
|
|
340
|
-
properties: {
|
|
341
|
-
platform: {
|
|
342
|
-
type: "string",
|
|
343
|
-
enum: ["android", "ios"],
|
|
344
|
-
description: "Platform to check"
|
|
345
|
-
},
|
|
346
|
-
text: {
|
|
347
|
-
type: "string",
|
|
348
|
-
description: "Text content of the element to wait for"
|
|
349
|
-
},
|
|
350
|
-
timeout: {
|
|
351
|
-
type: "number",
|
|
352
|
-
description: "Max wait time in ms (default 10000)",
|
|
353
|
-
default: 10000
|
|
354
|
-
},
|
|
355
|
-
deviceId: {
|
|
356
|
-
type: "string",
|
|
357
|
-
description: "Device Serial/UDID. Defaults to connected/booted device."
|
|
358
|
-
}
|
|
359
|
-
},
|
|
360
|
-
required: ["platform", "text"]
|
|
361
|
-
}
|
|
362
|
-
},
|
|
363
335
|
{
|
|
364
336
|
name: "find_element",
|
|
365
337
|
description: "Find a UI element by semantic query (text, content-desc, resource-id, class). Returns best match.",
|
|
@@ -644,9 +616,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
644
616
|
const res = await ToolsInteract.waitForScreenChangeHandler({ platform, previousFingerprint, timeoutMs, pollIntervalMs, deviceId });
|
|
645
617
|
return wrapResponse(res);
|
|
646
618
|
}
|
|
647
|
-
if (name === "
|
|
648
|
-
const {
|
|
649
|
-
const res = await ToolsInteract.
|
|
619
|
+
if (name === "wait_for_ui") {
|
|
620
|
+
const { type = 'ui', query, timeoutMs = 30000, pollIntervalMs = 300, includeSnapshotOnFailure = true, match = 'present', stability_ms = 700, observationDelayMs = 0, platform, deviceId } = (args || {});
|
|
621
|
+
const res = await ToolsInteract.waitForUIHandler({ type, query, timeoutMs, pollIntervalMs, includeSnapshotOnFailure, match, stability_ms, observationDelayMs, platform, deviceId });
|
|
650
622
|
return wrapResponse(res);
|
|
651
623
|
}
|
|
652
624
|
if (name === "find_element") {
|
|
@@ -23,6 +23,11 @@ export async function listDevices(platform, appId) {
|
|
|
23
23
|
export async function resolveTargetDevice(opts) {
|
|
24
24
|
const { platform, appId, prefer, deviceId } = opts;
|
|
25
25
|
const devices = await listDevices(platform, appId);
|
|
26
|
+
// During unit tests (no adb/xcrun available), provide a lightweight mock device so
|
|
27
|
+
// the observe/interact unit tests can run without real devices.
|
|
28
|
+
if ((!devices || devices.length === 0) && (process.env.NODE_ENV === 'test' || process.env.MCP_TEST_MOCK_DEVICES === '1')) {
|
|
29
|
+
return { id: 'mock', platform: platform || 'android', osVersion: '12', model: 'Pixel', simulator: true };
|
|
30
|
+
}
|
|
26
31
|
if (deviceId) {
|
|
27
32
|
const found = devices.find(d => d.id === deviceId);
|
|
28
33
|
if (!found)
|
package/docs/CHANGELOG.md
CHANGED
|
@@ -2,8 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to the **Mobile Debug MCP** project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.21.1]
|
|
6
|
+
- Removed wait_for_element and renamed observe_until to wait_for_ui (obsolete references removed)
|
|
7
|
+
|
|
5
8
|
## [0.21.0]
|
|
6
|
-
- Added `
|
|
9
|
+
- Added `wait_for_ui` as a tool for agents to wait for things like API requests
|
|
7
10
|
|
|
8
11
|
## [0.20.1]
|
|
9
12
|
- Fixes gradle home issue for android
|
|
@@ -24,7 +27,7 @@ All notable changes to the **Mobile Debug MCP** project will be documented in th
|
|
|
24
27
|
|
|
25
28
|
## [0.19.0]
|
|
26
29
|
|
|
27
|
-
- Added `
|
|
30
|
+
- Added `wait_for_ui` interaction tool: waits for UI, log, screen fingerprint or idle conditions with configurable polling and timeout. Returns rich details on match (element info, log line, new fingerprint).
|
|
28
31
|
|
|
29
32
|
|
|
30
33
|
## [0.18.0]
|
package/docs/tools/interact.md
CHANGED
|
@@ -2,26 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
Tools that perform UI interactions: tap, swipe, type_text, press_back, and waiting for elements.
|
|
4
4
|
|
|
5
|
-
## wait_for_element
|
|
6
|
-
Wait until a UI element with matching text appears on screen or timeout is reached.
|
|
7
|
-
|
|
8
|
-
Input:
|
|
9
|
-
|
|
10
|
-
```
|
|
11
|
-
{ "platform": "android", "text": "Home", "timeout": 5000, "deviceId": "emulator-5554" }
|
|
12
|
-
```
|
|
13
|
-
|
|
14
|
-
Response:
|
|
15
|
-
|
|
16
|
-
```
|
|
17
|
-
{ "device": { "platform": "android", "id": "emulator-5554" }, "found": true, "element": { "text": "Home", "resourceId": "com.example:id/home" } }
|
|
18
|
-
```
|
|
19
|
-
|
|
20
|
-
Notes:
|
|
21
|
-
- Polls get_ui_tree until timeout or element found. Returns an `error` field if system failures occur.
|
|
22
|
-
|
|
23
|
-
---
|
|
24
|
-
|
|
25
5
|
## tap / swipe / type_text / press_back
|
|
26
6
|
|
|
27
7
|
Tap input example:
|
|
@@ -153,7 +133,7 @@ Notes:
|
|
|
153
133
|
|
|
154
134
|
---
|
|
155
135
|
|
|
156
|
-
##
|
|
136
|
+
## wait_for_ui
|
|
157
137
|
|
|
158
138
|
Purpose:
|
|
159
139
|
- Wait for a condition to occur on the device: UI element appearance, a log line, a screen fingerprint change, or an idle/stable screen state.
|
|
@@ -164,7 +144,7 @@ Supported types and behavior:
|
|
|
164
144
|
- screen: Compares screen fingerprints (visual checks) against an initial baseline and returns when fingerprint changes. If `query` is provided it will attempt a `find_element` on the new screen to validate the expected content.
|
|
165
145
|
- idle: Waits until the screen fingerprint remains stable for a short stability window (default 1000ms).
|
|
166
146
|
|
|
167
|
-
Input (ToolsInteract.
|
|
147
|
+
Input (ToolsInteract.waitForUIHandler):
|
|
168
148
|
```
|
|
169
149
|
{ "type": "ui|log|screen|idle", "query": "optional string", "timeoutMs": 5000, "pollIntervalMs": 200, "platform": "android|ios", "deviceId": "optional device id" }
|
|
170
150
|
```
|
|
@@ -190,16 +170,16 @@ Notes & tips:
|
|
|
190
170
|
- For UI-sensitive flows prefer type='ui' rather than relying solely on visual fingerprint changes, as some UI updates don't alter the fingerprint.
|
|
191
171
|
|
|
192
172
|
Tests:
|
|
193
|
-
- Unit: `test/interact/unit/
|
|
194
|
-
- Device runner: `test/interact/device/
|
|
173
|
+
- Unit: `test/interact/unit/wait_for_ui.test.ts`
|
|
174
|
+
- Device runner: `test/interact/device/wait_for_ui_device.ts` (requires devices/emulators and adb/xcrun in PATH)
|
|
195
175
|
|
|
196
176
|
Example:
|
|
197
177
|
```
|
|
198
178
|
// Wait up to 5s for a button labeled "Generate Session" on Android
|
|
199
|
-
ToolsInteract.
|
|
179
|
+
ToolsInteract.waitForUIHandler({ type: 'ui', query: 'Generate Session', timeoutMs: 5000, platform: 'android' })
|
|
200
180
|
```
|
|
201
181
|
|
|
202
182
|
Troubleshooting:
|
|
203
|
-
- If
|
|
204
|
-
- If
|
|
183
|
+
- If wait_for_ui(log) never matches, ensure log streaming is started for the target package and baseline logs captured correctly.
|
|
184
|
+
- If wait_for_ui(screen) times out despite visible UI change, try type='ui' to validate content-level changes.
|
|
205
185
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mobile-debug-mcp",
|
|
3
|
-
"version": "0.21.
|
|
3
|
+
"version": "0.21.1",
|
|
4
4
|
"description": "MCP server for mobile app debugging (Android + iOS), with focus on security and reliability",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"healthcheck": "tsx ./src/cli/idb/check-idb.ts",
|
|
14
14
|
"install-idb": "tsx ./src/cli/idb/install-idb.ts",
|
|
15
15
|
"preflight-ios": "tsx ./src/cli/ios/preflight-ios.ts",
|
|
16
|
-
"test:unit": "tsx test/unit/index.ts",
|
|
16
|
+
"test:unit": "SKIP_DEVICE_TESTS=1 tsx test/unit/index.ts",
|
|
17
17
|
"test:integration": "npm run build && tsx test/device/index.ts",
|
|
18
18
|
"test:device": "npm run build && tsx test/device/index.ts",
|
|
19
19
|
"test": "npm run test:unit",
|
package/src/interact/android.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { TapResponse, SwipeResponse, TypeTextResponse, PressBackResponse } from "../types.js"
|
|
2
2
|
import { execAdb, getAndroidDeviceMetadata, getDeviceInfo } from "../utils/android/utils.js"
|
|
3
3
|
import { AndroidObserve } from "../observe/index.js"
|
|
4
4
|
import { scrollToElementShared } from "../utils/ui/index.js"
|
|
@@ -7,37 +7,6 @@ import { scrollToElementShared } from "../utils/ui/index.js"
|
|
|
7
7
|
export class AndroidInteract {
|
|
8
8
|
private observe = new AndroidObserve();
|
|
9
9
|
|
|
10
|
-
async waitForElement(text: string, timeout: number, deviceId?: string): Promise<WaitForElementResponse> {
|
|
11
|
-
const metadata = await getAndroidDeviceMetadata("", deviceId)
|
|
12
|
-
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
|
|
13
|
-
const startTime = Date.now();
|
|
14
|
-
|
|
15
|
-
while (Date.now() - startTime < timeout) {
|
|
16
|
-
try {
|
|
17
|
-
const tree = await this.observe.getUITree(deviceId);
|
|
18
|
-
|
|
19
|
-
if (tree.error) {
|
|
20
|
-
return { device: deviceInfo, found: false, error: tree.error };
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
const element = tree.elements.find(e => e.text === text);
|
|
24
|
-
if (element) {
|
|
25
|
-
return { device: deviceInfo, found: true, element };
|
|
26
|
-
}
|
|
27
|
-
} catch (e) {
|
|
28
|
-
// Ignore errors during polling and retry
|
|
29
|
-
console.error("Error polling UI tree:", e);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
const elapsed = Date.now() - startTime;
|
|
33
|
-
const remaining = timeout - elapsed;
|
|
34
|
-
if (remaining <= 0) break;
|
|
35
|
-
|
|
36
|
-
await new Promise(resolve => setTimeout(resolve, Math.min(500, remaining)));
|
|
37
|
-
}
|
|
38
|
-
return { device: deviceInfo, found: false };
|
|
39
|
-
}
|
|
40
|
-
|
|
41
10
|
async tap(x: number, y: number, deviceId?: string): Promise<TapResponse> {
|
|
42
11
|
const metadata = await getAndroidDeviceMetadata("", deviceId)
|
|
43
12
|
const deviceInfo = getDeviceInfo(deviceId || 'default', metadata)
|
package/src/interact/index.ts
CHANGED
|
@@ -39,12 +39,6 @@ export class ToolsInteract {
|
|
|
39
39
|
return { interact: interact as any, resolved, platform: effectivePlatform }
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
static async waitForElementHandler({ platform, text, timeout, deviceId }: { platform: 'android' | 'ios', text: string, timeout?: number, deviceId?: string }) {
|
|
43
|
-
const effectiveTimeout = timeout ?? 10000
|
|
44
|
-
const { interact, resolved } = await ToolsInteract.getInteractionService(platform, deviceId)
|
|
45
|
-
return await interact.waitForElement(text, effectiveTimeout, resolved.id)
|
|
46
|
-
}
|
|
47
|
-
|
|
48
42
|
static async tapHandler({ platform, x, y, deviceId }: { platform?: 'android' | 'ios', x: number, y: number, deviceId?: string }) {
|
|
49
43
|
const { interact, resolved } = await ToolsInteract.getInteractionService(platform, deviceId)
|
|
50
44
|
return await interact.tap(x, y, resolved.id)
|
|
@@ -224,6 +218,11 @@ export class ToolsInteract {
|
|
|
224
218
|
return { found: true, element: outEl, score: scoreVal, confidence: scoreVal }
|
|
225
219
|
}
|
|
226
220
|
|
|
221
|
+
static async waitForUIHandler({ type = 'ui', query, timeoutMs = 30000, pollIntervalMs = 300, includeSnapshotOnFailure = true, match = 'present', stability_ms = 700, observationDelayMs = 0, platform, deviceId }: { type?: 'ui' | 'log' | 'screen' | 'idle', query?: string, timeoutMs?: number, pollIntervalMs?: number, includeSnapshotOnFailure?: boolean, match?: 'present'|'absent', stability_ms?: number, observationDelayMs?: number, platform?: 'android' | 'ios', deviceId?: string }) {
|
|
222
|
+
// Backwards-compatible wrapper that delegates to the core waitForUICore implementation
|
|
223
|
+
return await ToolsInteract.waitForUICore({ type, query, timeoutMs, pollIntervalMs, includeSnapshotOnFailure, match, stability_ms, observationDelayMs, platform, deviceId })
|
|
224
|
+
}
|
|
225
|
+
|
|
227
226
|
static async waitForScreenChangeHandler({ platform, previousFingerprint, timeoutMs = 5000, pollIntervalMs = 300, deviceId }: { platform?: 'android' | 'ios', previousFingerprint: string, timeoutMs?: number, pollIntervalMs?: number, deviceId?: string }) {
|
|
228
227
|
const start = Date.now()
|
|
229
228
|
let lastFingerprint: string | null = null
|
|
@@ -261,7 +260,7 @@ export class ToolsInteract {
|
|
|
261
260
|
return { success: false, reason: 'timeout', lastFingerprint, elapsedMs: Date.now() - start }
|
|
262
261
|
}
|
|
263
262
|
|
|
264
|
-
static async
|
|
263
|
+
static async waitForUICore({ type = 'ui', query, timeoutMs = 30000, pollIntervalMs = 300, includeSnapshotOnFailure = true, match = 'present', stability_ms = 700, observationDelayMs = 0, platform, deviceId }: { type?: 'ui' | 'log' | 'screen' | 'idle', query?: string, timeoutMs?: number, pollIntervalMs?: number, includeSnapshotOnFailure?: boolean, match?: 'present'|'absent', stability_ms?: number, observationDelayMs?: number, platform?: 'android' | 'ios', deviceId?: string }) {
|
|
265
264
|
const start = Date.now()
|
|
266
265
|
const deadline = start + (timeoutMs || 0)
|
|
267
266
|
const q = (query === null || query === undefined) ? '' : String(query)
|
|
@@ -283,7 +282,7 @@ export class ToolsInteract {
|
|
|
283
282
|
baselineLastLine = logsArr.length ? logsArr[logsArr.length - 1] : null
|
|
284
283
|
}
|
|
285
284
|
} catch (err) {
|
|
286
|
-
try { console.warn('
|
|
285
|
+
try { console.warn('waitForUI: failed to get baseline data (non-fatal):', err instanceof Error ? err.message : String(err)) } catch { }
|
|
287
286
|
}
|
|
288
287
|
|
|
289
288
|
// Network-based waiting removed. Rely on UI and screen fingerprints for determinism.
|
|
@@ -294,7 +293,7 @@ export class ToolsInteract {
|
|
|
294
293
|
|
|
295
294
|
// Optional initial observation delay requested by caller
|
|
296
295
|
if (typeof observationDelayMs === 'number' && observationDelayMs > 0) {
|
|
297
|
-
try { console.log(`
|
|
296
|
+
try { console.log(`waitForUI: delaying observation for ${observationDelayMs}ms`) } catch { }
|
|
298
297
|
await sleep(observationDelayMs)
|
|
299
298
|
}
|
|
300
299
|
|
|
@@ -311,40 +310,29 @@ export class ToolsInteract {
|
|
|
311
310
|
// Evaluate condition per type
|
|
312
311
|
if (type === 'ui') {
|
|
313
312
|
try {
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
lastObservedState = true
|
|
335
|
-
if (stableDuration >= stability_ms) {
|
|
336
|
-
matchSource = 'ui-tree-' + (match === 'present' ? 'present' : 'absent')
|
|
337
|
-
const element = isPresent ? matched : null
|
|
338
|
-
const now2 = Date.now()
|
|
339
|
-
return { success: true, condition: match, query: q, poll_count: pollCount, duration_ms: now2 - start, stable_duration_ms: stableDuration, matchedElement: element, matchSource, timestamp: now2, type: 'ui', observed_state: lastObservedState ?? null }
|
|
313
|
+
// Prefer using the public findElementHandler which tests can override. This avoids relying
|
|
314
|
+
// on resolveObserve/getUITree for unit tests which may not have devices available.
|
|
315
|
+
try {
|
|
316
|
+
const findRes = await (ToolsInteract as any).findElementHandler({ query: q, exact: false, timeoutMs: Math.min(500, pollInterval), platform, deviceId })
|
|
317
|
+
const isPresent = !!(findRes && (findRes as any).found)
|
|
318
|
+
const conditionTrue = (match === 'present') ? isPresent : !isPresent
|
|
319
|
+
if (conditionTrue) {
|
|
320
|
+
if (matchedAt === null) matchedAt = Date.now()
|
|
321
|
+
stableDuration = Date.now() - (matchedAt as number)
|
|
322
|
+
lastObservedState = true
|
|
323
|
+
if (stableDuration >= stability_ms) {
|
|
324
|
+
matchSource = 'ui-find'
|
|
325
|
+
const element = isPresent ? (findRes as any).element : null
|
|
326
|
+
const now2 = Date.now()
|
|
327
|
+
return { success: true, condition: match, query: q, poll_count: pollCount, duration_ms: now2 - start, stable_duration_ms: stableDuration, matchedElement: element, matchSource, timestamp: now2, type: 'ui', observed_state: lastObservedState ?? null }
|
|
328
|
+
}
|
|
329
|
+
} else {
|
|
330
|
+
matchedAt = null
|
|
331
|
+
stableDuration = 0
|
|
332
|
+
lastObservedState = false
|
|
340
333
|
}
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
stableDuration = 0
|
|
344
|
-
lastObservedState = false
|
|
345
|
-
}
|
|
346
|
-
} catch (err) { console.error('observeUntil(ui) tree error:', err) }
|
|
347
|
-
} catch (err) { console.error('observeUntil(ui) find error:', err) }
|
|
334
|
+
} catch (err) { console.error('waitForUI(ui) find error:', err) }
|
|
335
|
+
} catch (err) { console.error('waitForUI(ui) outer error:', err) }
|
|
348
336
|
} else if (type === 'log') {
|
|
349
337
|
try {
|
|
350
338
|
// Logs: presence semantics only (match 'present'). Stability not applicable (immediate)
|
|
@@ -372,7 +360,7 @@ export class ToolsInteract {
|
|
|
372
360
|
return { success: true, condition: 'present', query: q, poll_count: pollCount, duration_ms: now2 - start, stable_duration_ms: 0, matchedLog: { message: line }, matchSource: 'log-snapshot', timestamp: now2, type: 'log', observed_state: true }
|
|
373
361
|
}
|
|
374
362
|
}
|
|
375
|
-
} catch (err) { console.error('
|
|
363
|
+
} catch (err) { console.error('waitForUI(log) error:', err) }
|
|
376
364
|
} else if (type === 'screen') {
|
|
377
365
|
try {
|
|
378
366
|
const fpRes = await ToolsObserve.getScreenFingerprintHandler({ platform, deviceId }) as ScreenFingerprintResponse | null
|
|
@@ -394,7 +382,7 @@ export class ToolsInteract {
|
|
|
394
382
|
lastObservedState = false
|
|
395
383
|
}
|
|
396
384
|
}
|
|
397
|
-
} catch (err) { console.error('
|
|
385
|
+
} catch (err) { console.error('waitForUI(screen) error:', err) }
|
|
398
386
|
} else if (type === 'idle') {
|
|
399
387
|
try {
|
|
400
388
|
const fpRes = await ToolsObserve.getScreenFingerprintHandler({ platform, deviceId }) as ScreenFingerprintResponse | null
|
|
@@ -413,7 +401,7 @@ export class ToolsInteract {
|
|
|
413
401
|
return { success: true, condition: 'present', query: q, poll_count: pollCount, duration_ms: now2 - start, stable_duration_ms: idleMs, matchSource: 'idle-stable', timestamp: now2, type: 'idle', observed_state: lastObservedState ?? null }
|
|
414
402
|
}
|
|
415
403
|
}
|
|
416
|
-
} catch (err) { console.error('
|
|
404
|
+
} catch (err) { console.error('waitForUI(idle) error:', err) }
|
|
417
405
|
}
|
|
418
406
|
|
|
419
407
|
// Respect poll interval and avoid tight loop
|
|
@@ -424,7 +412,11 @@ export class ToolsInteract {
|
|
|
424
412
|
let snapshot: any = null
|
|
425
413
|
if (includeSnapshotOnFailure) {
|
|
426
414
|
try {
|
|
427
|
-
|
|
415
|
+
// Use dynamic import to avoid circular-initialization issues where the ToolsObserve
|
|
416
|
+
// binding captured earlier may not reflect test-time overrides. Importing at call
|
|
417
|
+
// time ensures the latest exported ToolsObserve object is used.
|
|
418
|
+
const Obs = await import('../observe/index.js')
|
|
419
|
+
snapshot = await (Obs as any).ToolsObserve.captureDebugSnapshotHandler({ reason: `wait_for_ui timeout for ${type}`, includeLogs: true, platform, deviceId })
|
|
428
420
|
} catch (err) {
|
|
429
421
|
snapshot = { error: err instanceof Error ? err.message : String(err) }
|
|
430
422
|
}
|
package/src/interact/ios.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn } from "child_process"
|
|
2
|
-
import {
|
|
2
|
+
import { TapResponse, SwipeResponse } from "../types.js"
|
|
3
3
|
import { getIOSDeviceMetadata, getIdbCmd, isIDBInstalled } from "../utils/ios/utils.js"
|
|
4
4
|
import { iOSObserve } from "../observe/index.js"
|
|
5
5
|
import { scrollToElementShared } from "../utils/ui/index.js"
|
|
@@ -7,36 +7,6 @@ import { scrollToElementShared } from "../utils/ui/index.js"
|
|
|
7
7
|
export class iOSInteract {
|
|
8
8
|
private observe = new iOSObserve();
|
|
9
9
|
|
|
10
|
-
async waitForElement(text: string, timeout: number, deviceId: string = "booted"): Promise<WaitForElementResponse> {
|
|
11
|
-
const device = await getIOSDeviceMetadata(deviceId);
|
|
12
|
-
const startTime = Date.now();
|
|
13
|
-
|
|
14
|
-
while (Date.now() - startTime < timeout) {
|
|
15
|
-
try {
|
|
16
|
-
const tree = await this.observe.getUITree(deviceId);
|
|
17
|
-
|
|
18
|
-
if (tree.error) {
|
|
19
|
-
return { device, found: false, error: tree.error };
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
const element = tree.elements.find(e => e.text === text);
|
|
23
|
-
if (element) {
|
|
24
|
-
return { device, found: true, element };
|
|
25
|
-
}
|
|
26
|
-
} catch (e) {
|
|
27
|
-
// Ignore errors during polling and retry
|
|
28
|
-
console.error("Error polling UI tree:", e);
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
const elapsed = Date.now() - startTime;
|
|
32
|
-
const remaining = timeout - elapsed;
|
|
33
|
-
if (remaining <= 0) break;
|
|
34
|
-
|
|
35
|
-
await new Promise(resolve => setTimeout(resolve, Math.min(500, remaining)));
|
|
36
|
-
}
|
|
37
|
-
return { device, found: false };
|
|
38
|
-
}
|
|
39
|
-
|
|
40
10
|
async tap(x: number, y: number, deviceId: string = "booted"): Promise<TapResponse> {
|
|
41
11
|
const device = await getIOSDeviceMetadata(deviceId)
|
|
42
12
|
|
package/src/server.ts
CHANGED
|
@@ -340,8 +340,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
340
340
|
}
|
|
341
341
|
},
|
|
342
342
|
{
|
|
343
|
-
name: "
|
|
344
|
-
description: "Wait for a UI condition
|
|
343
|
+
name: "wait_for_ui",
|
|
344
|
+
description: "Wait for a UI/log/screen/idle condition with a stability window before returning success.",
|
|
345
345
|
inputSchema: {
|
|
346
346
|
type: "object",
|
|
347
347
|
properties: {
|
|
@@ -359,34 +359,6 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
359
359
|
},
|
|
360
360
|
|
|
361
361
|
|
|
362
|
-
{
|
|
363
|
-
name: "wait_for_element",
|
|
364
|
-
description: "Wait until a UI element with matching text appears on screen or timeout is reached.",
|
|
365
|
-
inputSchema: {
|
|
366
|
-
type: "object",
|
|
367
|
-
properties: {
|
|
368
|
-
platform: {
|
|
369
|
-
type: "string",
|
|
370
|
-
enum: ["android", "ios"],
|
|
371
|
-
description: "Platform to check"
|
|
372
|
-
},
|
|
373
|
-
text: {
|
|
374
|
-
type: "string",
|
|
375
|
-
description: "Text content of the element to wait for"
|
|
376
|
-
},
|
|
377
|
-
timeout: {
|
|
378
|
-
type: "number",
|
|
379
|
-
description: "Max wait time in ms (default 10000)",
|
|
380
|
-
default: 10000
|
|
381
|
-
},
|
|
382
|
-
deviceId: {
|
|
383
|
-
type: "string",
|
|
384
|
-
description: "Device Serial/UDID. Defaults to connected/booted device."
|
|
385
|
-
}
|
|
386
|
-
},
|
|
387
|
-
required: ["platform", "text"]
|
|
388
|
-
}
|
|
389
|
-
},
|
|
390
362
|
{
|
|
391
363
|
name: "find_element",
|
|
392
364
|
description: "Find a UI element by semantic query (text, content-desc, resource-id, class). Returns best match.",
|
|
@@ -694,9 +666,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request: SchemaOutput<typ
|
|
|
694
666
|
return wrapResponse(res)
|
|
695
667
|
}
|
|
696
668
|
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
const
|
|
669
|
+
|
|
670
|
+
if (name === "wait_for_ui") {
|
|
671
|
+
const { type = 'ui', query, timeoutMs = 30000, pollIntervalMs = 300, includeSnapshotOnFailure = true, match = 'present', stability_ms = 700, observationDelayMs = 0, platform, deviceId } = (args || {}) as any
|
|
672
|
+
const res = await ToolsInteract.waitForUIHandler({ type, query, timeoutMs, pollIntervalMs, includeSnapshotOnFailure, match, stability_ms, observationDelayMs, platform, deviceId })
|
|
700
673
|
return wrapResponse(res)
|
|
701
674
|
}
|
|
702
675
|
|
|
@@ -32,6 +32,12 @@ export async function resolveTargetDevice(opts: ResolveOptions): Promise<DeviceI
|
|
|
32
32
|
const { platform, appId, prefer, deviceId } = opts
|
|
33
33
|
const devices = await listDevices(platform, appId)
|
|
34
34
|
|
|
35
|
+
// During unit tests (no adb/xcrun available), provide a lightweight mock device so
|
|
36
|
+
// the observe/interact unit tests can run without real devices.
|
|
37
|
+
if ((!devices || devices.length === 0) && (process.env.NODE_ENV === 'test' || process.env.MCP_TEST_MOCK_DEVICES === '1')) {
|
|
38
|
+
return { id: 'mock', platform: platform || 'android', osVersion: '12', model: 'Pixel', simulator: true } as DeviceInfo
|
|
39
|
+
}
|
|
40
|
+
|
|
35
41
|
if (deviceId) {
|
|
36
42
|
const found = devices.find(d => d.id === deviceId)
|
|
37
43
|
if (!found) throw new Error(`Device '${deviceId}' not found for platform ${platform}`)
|
|
@@ -1,19 +1,3 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
const __filename = fileURLToPath(import.meta.url);
|
|
5
|
-
const __dirname = path.dirname(__filename);
|
|
6
|
-
const ADB_PATH = process.env.ADB_PATH || process.env.ADB || 'adb';
|
|
7
|
-
const TEST_FILE = path.join(__dirname, 'wait_for_element_real.ts');
|
|
8
|
-
|
|
9
|
-
const childEnv = { ...process.env, ADB_PATH };
|
|
10
|
-
const runner = process.env.RUNNER || 'npx';
|
|
11
|
-
const runnerArgs = ['tsx', TEST_FILE];
|
|
12
|
-
|
|
13
|
-
const child = spawn(runner, runnerArgs, {
|
|
14
|
-
env: childEnv,
|
|
15
|
-
stdio: 'inherit'
|
|
16
|
-
});
|
|
17
|
-
child.on('exit', (code) => {
|
|
18
|
-
process.exit(code || 0);
|
|
19
|
-
});
|
|
1
|
+
// wait_for_element device runner removed
|
|
2
|
+
console.log('wait_for_element device runner removed');
|
|
3
|
+
process.exit(0);
|
|
@@ -2,7 +2,7 @@ import { ToolsInteract } from '../../../src/interact/index.js'
|
|
|
2
2
|
import * as Observe from '../../../src/observe/index.js'
|
|
3
3
|
|
|
4
4
|
async function runTests() {
|
|
5
|
-
console.log('Starting
|
|
5
|
+
console.log('Starting wait_for_ui unit tests...')
|
|
6
6
|
|
|
7
7
|
const origFind = (ToolsInteract as any).findElementHandler
|
|
8
8
|
const origReadLog = (Observe as any).ToolsObserve.readLogStreamHandler
|
|
@@ -17,7 +17,7 @@ async function runTests() {
|
|
|
17
17
|
;(Observe as any).ToolsObserve.captureDebugSnapshotHandler = async ({ reason }: any) => ({ reason, fingerprint: 'snap-123', ui_tree: null, logs: [] })
|
|
18
18
|
// make findElement always fail
|
|
19
19
|
(ToolsInteract as any).findElementHandler = async () => ({ found: false })
|
|
20
|
-
const resTimeout = await ToolsInteract.
|
|
20
|
+
const resTimeout = await ToolsInteract.waitForUIHandler({ type: 'ui', query: 'WillNeverExist', timeoutMs: 500, pollIntervalMs: 100, platform: 'android' })
|
|
21
21
|
const okTimeout = resTimeout && !(resTimeout as any).success && (resTimeout as any).snapshot && (resTimeout as any).snapshot.fingerprint === 'snap-123' && (resTimeout as any).telemetry && (resTimeout as any).telemetry.pollCount > 0
|
|
22
22
|
console.log('Timeout Snapshot Test:', okTimeout ? 'PASS' : 'FAIL', JSON.stringify((resTimeout as any).telemetry || {}, null, 2))
|
|
23
23
|
;(Observe as any).ToolsObserve.captureDebugSnapshotHandler = origCapture
|
|
@@ -31,7 +31,7 @@ async function runTests() {
|
|
|
31
31
|
return { found: false }
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
const resUi = await ToolsInteract.
|
|
34
|
+
const resUi = await ToolsInteract.waitForUIHandler({ type: 'ui', query: 'Generate Session', timeoutMs: 3000, pollIntervalMs: 100, platform: 'android' })
|
|
35
35
|
const okUi = resUi && (resUi as any).success && (resUi as any).telemetry && (resUi as any).telemetry.pollCount > 0 && (resUi as any).telemetry.timeToMatch >= 0
|
|
36
36
|
console.log('UI Test:', okUi ? 'PASS' : 'FAIL', JSON.stringify((resUi as any).telemetry || {}, null, 2))
|
|
37
37
|
|
|
@@ -44,21 +44,21 @@ async function runTests() {
|
|
|
44
44
|
return { device: {}, logs: ['INFO start', 'ERROR Exception occurred', 'Server: Boom'] }
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
const resLog = await ToolsInteract.
|
|
47
|
+
const resLog = await ToolsInteract.waitForUIHandler({ type: 'log', query: 'Server', timeoutMs: 3000, pollIntervalMs: 100, platform: 'android' })
|
|
48
48
|
const okLog = resLog && (resLog as any).success && (resLog as any).telemetry && (resLog as any).telemetry.pollCount > 0 && (resLog as any).telemetry.matchSource === 'log-snapshot'
|
|
49
49
|
console.log('Log Test:', okLog ? 'PASS' : 'FAIL', JSON.stringify((resLog as any).telemetry || {}, null, 2))
|
|
50
50
|
|
|
51
51
|
// Screen condition: fingerprint changes after a few polls
|
|
52
52
|
let seq = ['A', 'A', 'B']
|
|
53
53
|
;(Observe as any).ToolsObserve.getScreenFingerprintHandler = async () => ({ fingerprint: seq.length ? seq.shift() : null })
|
|
54
|
-
const resScreen = await ToolsInteract.
|
|
54
|
+
const resScreen = await ToolsInteract.waitForUIHandler({ type: 'screen', timeoutMs: 3000, pollIntervalMs: 100, platform: 'android' })
|
|
55
55
|
const okScreen = resScreen && (resScreen as any).success && (resScreen as any).telemetry && (resScreen as any).telemetry.matchSource === 'screen-fingerprint'
|
|
56
56
|
console.log('Screen Test:', okScreen ? 'PASS' : 'FAIL', JSON.stringify((resScreen as any).telemetry || {}, null, 2))
|
|
57
57
|
|
|
58
58
|
// Idle condition: stable fingerprints observed
|
|
59
59
|
let idleSeq = ['X', 'X', 'X']
|
|
60
60
|
;(Observe as any).ToolsObserve.getScreenFingerprintHandler = async () => ({ fingerprint: idleSeq.length ? idleSeq.shift() : 'X' })
|
|
61
|
-
const resIdle = await ToolsInteract.
|
|
61
|
+
const resIdle = await ToolsInteract.waitForUIHandler({ type: 'idle', timeoutMs: 3000, pollIntervalMs: 100, platform: 'android' })
|
|
62
62
|
const okIdle = resIdle && (resIdle as any).success && (resIdle as any).telemetry && (resIdle as any).telemetry.matchSource === 'idle-stable'
|
|
63
63
|
console.log('Idle Test:', okIdle ? 'PASS' : 'FAIL', JSON.stringify((resIdle as any).telemetry || {}, null, 2))
|
|
64
64
|
|
|
@@ -1,80 +1,3 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
// Usage: npx tsx test/wait_for_element_real.ts <deviceId> <appId>
|
|
5
|
-
const args = process.argv.slice(2);
|
|
6
|
-
const DEVICE_ID = args[0] || process.env.DEVICE_ID;
|
|
7
|
-
const APP_ID = args[1] || process.env.APP_ID;
|
|
8
|
-
|
|
9
|
-
if (!DEVICE_ID || !APP_ID) {
|
|
10
|
-
console.error("Usage: npx tsx test/wait_for_element_real.ts <deviceId> <appId> or set DEVICE_ID and APP_ID env vars");
|
|
11
|
-
process.exit(1);
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
async function runRealTest() {
|
|
15
|
-
console.log(`Connecting to device ${DEVICE_ID}...`);
|
|
16
|
-
const interact = new AndroidInteract();
|
|
17
|
-
const observe = new AndroidObserve();
|
|
18
|
-
try {
|
|
19
|
-
console.log(`\nStarting app ${APP_ID}...`);
|
|
20
|
-
await interact.startApp(APP_ID, DEVICE_ID);
|
|
21
|
-
console.log("Waiting 3s for app to render...");
|
|
22
|
-
await new Promise(r => setTimeout(r, 3000));
|
|
23
|
-
|
|
24
|
-
console.log("\nFetching UI Tree to find a target text...");
|
|
25
|
-
const tree = await observe.getUITree(DEVICE_ID);
|
|
26
|
-
if (tree.error) {
|
|
27
|
-
console.error("Failed to get UI Tree:", tree.error);
|
|
28
|
-
return;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
const targetElement = tree.elements.find(e => e.text && e.text.length > 0 && e.visible);
|
|
32
|
-
if (!targetElement || !targetElement.text) {
|
|
33
|
-
console.warn("No visible text elements found on screen to test with.");
|
|
34
|
-
console.log("Elements found:", tree.elements.length);
|
|
35
|
-
return;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
const targetText = targetElement.text;
|
|
39
|
-
console.log(`Found target element: "${targetText}"`);
|
|
40
|
-
|
|
41
|
-
console.log(`\nTest 1: Waiting for existing element "${targetText}" (should succeed)...`);
|
|
42
|
-
const start1 = Date.now();
|
|
43
|
-
const result1 = await interact.waitForElement(targetText, 5000, DEVICE_ID);
|
|
44
|
-
const elapsed1 = Date.now() - start1;
|
|
45
|
-
console.log(`Result: ${result1.found ? "PASS" : "FAIL"}`);
|
|
46
|
-
console.log(`Found Element: ${result1.element?.text}`);
|
|
47
|
-
console.log(`Time taken: ${elapsed1}ms`);
|
|
48
|
-
|
|
49
|
-
const missingText = "THIS_TEXT_SHOULD_NOT_EXIST_XYZ_123";
|
|
50
|
-
console.log(`\nTest 2: Waiting for missing element "${missingText}" (should timeout)...`);
|
|
51
|
-
const start2 = Date.now();
|
|
52
|
-
const result2 = await interact.waitForElement(missingText, 2000, DEVICE_ID);
|
|
53
|
-
const elapsed2 = Date.now() - start2;
|
|
54
|
-
console.log(`Result: ${!result2.found ? "PASS" : "FAIL"}`);
|
|
55
|
-
console.log(`Found: ${result2.found}`);
|
|
56
|
-
console.log(`Time taken: ${elapsed2}ms (expected ~2000ms)`);
|
|
57
|
-
|
|
58
|
-
console.log(`\nTest 3: Found after polling`);
|
|
59
|
-
let calls = 0;
|
|
60
|
-
AndroidObserve.prototype.getUITree = async function() {
|
|
61
|
-
calls++;
|
|
62
|
-
if (calls < 3) {
|
|
63
|
-
return { device: { platform: "android", id: "mock", osVersion: "12", model: "Pixel", simulator: true }, screen: "", resolution: { width: 1080, height: 1920 }, elements: [] };
|
|
64
|
-
}
|
|
65
|
-
return { device: { platform: "android", id: "mock", osVersion: "12", model: "Pixel", simulator: true }, screen: "", resolution: { width: 1080, height: 1920 }, elements: [{ text: "Target", type: "Button", contentDescription: null, clickable: true, enabled: true, visible: true, bounds: [0,0,100,100], resourceId: null }] };
|
|
66
|
-
} as any;
|
|
67
|
-
|
|
68
|
-
const start3 = Date.now();
|
|
69
|
-
const result3 = await interact.waitForElement("Target", 2000, DEVICE_ID);
|
|
70
|
-
const elapsed3 = Date.now() - start3;
|
|
71
|
-
console.log(`Result: ${result3.found ? "PASS" : "FAIL"}`);
|
|
72
|
-
console.log(`Calls: ${calls} ${calls === 3 ? "PASS" : "FAIL"}`);
|
|
73
|
-
console.log(`Elapsed time (should be >= 1000ms): ${elapsed3} ${elapsed3 >= 1000 ? "PASS" : "FAIL"}`);
|
|
74
|
-
|
|
75
|
-
} catch {
|
|
76
|
-
console.error("Test failed with error:", error);
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
runRealTest();
|
|
1
|
+
// wait_for_element device runner removed
|
|
2
|
+
console.log('wait_for_element device test removed');
|
|
3
|
+
process.exit(0);
|
|
@@ -1,104 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
const originalGetUITree = (AndroidObserve as any).prototype.getUITree;
|
|
5
|
-
|
|
6
|
-
async function runTests() {
|
|
7
|
-
console.log("Starting tests for wait_for_element...");
|
|
8
|
-
const interact = new AndroidInteract();
|
|
9
|
-
|
|
10
|
-
console.log("\nTest 1: Element found immediately");
|
|
11
|
-
(AndroidObserve as any).prototype.getUITree = async () => ({
|
|
12
|
-
device: { platform: "android", id: "mock", osVersion: "12", model: "Pixel", simulator: true },
|
|
13
|
-
screen: "",
|
|
14
|
-
resolution: { width: 1080, height: 1920 },
|
|
15
|
-
elements: [{
|
|
16
|
-
text: "Target",
|
|
17
|
-
type: "Button",
|
|
18
|
-
contentDescription: null,
|
|
19
|
-
clickable: true,
|
|
20
|
-
enabled: true,
|
|
21
|
-
visible: true,
|
|
22
|
-
bounds: [0, 0, 100, 100],
|
|
23
|
-
resourceId: null
|
|
24
|
-
}]
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
const start1 = Date.now();
|
|
28
|
-
const result1 = await interact.waitForElement("Target", 1000);
|
|
29
|
-
const elapsed1 = Date.now() - start1;
|
|
30
|
-
console.log("Result:", result1.found === true ? "PASS" : "FAIL");
|
|
31
|
-
console.log("Element:", result1.element ? "FOUND" : "MISSING");
|
|
32
|
-
console.log("Elapsed:", elapsed1, "ms");
|
|
33
|
-
|
|
34
|
-
console.log("\nTest 2: Element not found (timeout)");
|
|
35
|
-
(AndroidObserve as any).prototype.getUITree = async () => ({
|
|
36
|
-
device: { platform: "android", id: "mock", osVersion: "12", model: "Pixel", simulator: true },
|
|
37
|
-
screen: "",
|
|
38
|
-
resolution: { width: 1080, height: 1920 },
|
|
39
|
-
elements: []
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
const start2 = Date.now();
|
|
43
|
-
const result2 = await interact.waitForElement("Target", 1200);
|
|
44
|
-
const elapsed2 = Date.now() - start2;
|
|
45
|
-
console.log("Result:", result2.found === false ? "PASS" : "FAIL");
|
|
46
|
-
console.log("Elapsed time (should be >= 1200ms):", elapsed2, elapsed2 >= 1200 ? "PASS" : "FAIL");
|
|
47
|
-
|
|
48
|
-
console.log("\nTest 3: Element found after polling");
|
|
49
|
-
let calls = 0;
|
|
50
|
-
(AndroidObserve as any).prototype.getUITree = async () => {
|
|
51
|
-
calls++;
|
|
52
|
-
if (calls < 3) {
|
|
53
|
-
return {
|
|
54
|
-
device: { platform: "android", id: "mock", osVersion: "12", model: "Pixel", simulator: true },
|
|
55
|
-
screen: "",
|
|
56
|
-
resolution: { width: 1080, height: 1920 },
|
|
57
|
-
elements: []
|
|
58
|
-
};
|
|
59
|
-
}
|
|
60
|
-
return {
|
|
61
|
-
device: { platform: "android", id: "mock", osVersion: "12", model: "Pixel", simulator: true },
|
|
62
|
-
screen: "",
|
|
63
|
-
resolution: { width: 1080, height: 1920 },
|
|
64
|
-
elements: [{
|
|
65
|
-
text: "Target",
|
|
66
|
-
type: "Button",
|
|
67
|
-
contentDescription: null,
|
|
68
|
-
clickable: true,
|
|
69
|
-
enabled: true,
|
|
70
|
-
visible: true,
|
|
71
|
-
bounds: [0, 0, 100, 100],
|
|
72
|
-
resourceId: null
|
|
73
|
-
}]
|
|
74
|
-
};
|
|
75
|
-
};
|
|
76
|
-
|
|
77
|
-
const start3 = Date.now();
|
|
78
|
-
const result3 = await interact.waitForElement("Target", 2000);
|
|
79
|
-
const elapsed3 = Date.now() - start3;
|
|
80
|
-
console.log("Result:", result3.found === true ? "PASS" : "FAIL");
|
|
81
|
-
console.log("Calls:", calls, calls >= 3 ? "PASS" : "FAIL");
|
|
82
|
-
console.log("Elapsed time (should be >= 1000ms):", elapsed3, elapsed3 >= 1000 ? "PASS" : "FAIL");
|
|
83
|
-
|
|
84
|
-
console.log("\nTest 4: Error handling (fast failure)");
|
|
85
|
-
(AndroidObserve as any).prototype.getUITree = async () => ({
|
|
86
|
-
device: { platform: "android", id: "mock", osVersion: "12", model: "Pixel", simulator: true },
|
|
87
|
-
screen: "",
|
|
88
|
-
resolution: { width: 0, height: 0 },
|
|
89
|
-
elements: [],
|
|
90
|
-
error: "ADB Connection Failed"
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
const start4 = Date.now();
|
|
94
|
-
const result4 = await interact.waitForElement("Target", 5000);
|
|
95
|
-
const elapsed4 = Date.now() - start4;
|
|
96
|
-
console.log("Result:", result4.found === false && result4.error === "ADB Connection Failed" ? "PASS" : "FAIL");
|
|
97
|
-
console.log("Error Message:", result4.error);
|
|
98
|
-
console.log("Elapsed time (should be < 1000ms):", elapsed4, elapsed4 < 1000 ? "PASS" : "FAIL");
|
|
99
|
-
|
|
100
|
-
// Restore
|
|
101
|
-
(AndroidObserve as any).prototype.getUITree = originalGetUITree;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
runTests().catch(console.error);
|
|
1
|
+
// wait_for_element tests removed — tool deprecated
|
|
2
|
+
console.log('wait_for_element unit tests removed');
|
package/test/observe/unit/{observe_until_edge_cases.test.ts → wait_for_ui_edge_cases.test.ts}
RENAMED
|
@@ -2,7 +2,7 @@ import { ToolsInteract } from '../../../../src/interact/index.js'
|
|
|
2
2
|
import * as Observe from '../../../../src/observe/index.js'
|
|
3
3
|
|
|
4
4
|
async function run() {
|
|
5
|
-
console.log('Unit:
|
|
5
|
+
console.log('Unit: wait_for_ui edge cases')
|
|
6
6
|
|
|
7
7
|
const origFind = (ToolsInteract as any).findElementHandler
|
|
8
8
|
const origFp = (Observe as any).ToolsObserve.getScreenFingerprintHandler
|
|
@@ -10,26 +10,26 @@ async function run() {
|
|
|
10
10
|
try {
|
|
11
11
|
// 1) Immediate absence should pass for match='absent'
|
|
12
12
|
(ToolsInteract as any).findElementHandler = async () => ({ found: false })
|
|
13
|
-
const r1 = await (ToolsInteract as any).
|
|
13
|
+
const r1 = await (ToolsInteract as any).waitForUIHandler({ type: 'ui', query: 'Nothing', timeoutMs: 2000, pollIntervalMs: 100, stability_ms: 200, match: 'absent', platform: 'android' })
|
|
14
14
|
console.log('Immediate absent test:', r1 && (r1 as any).success ? 'PASS' : 'FAIL', JSON.stringify({ poll_count: (r1 as any).poll_count, duration_ms: (r1 as any).duration_ms, stable_duration_ms: (r1 as any).stable_duration_ms, matchSource: (r1 as any).matchSource }, null, 2))
|
|
15
15
|
|
|
16
16
|
// 2) Boundary stability: condition becomes true and stays exactly long enough
|
|
17
17
|
// Use pollInterval 100ms and stability 300ms -> need ~3 consecutive trues
|
|
18
18
|
let seq2 = [false, true, true, true]
|
|
19
19
|
(ToolsInteract as any).findElementHandler = async () => ({ found: seq2.shift() ?? true })
|
|
20
|
-
const r2 = await (ToolsInteract as any).
|
|
20
|
+
const r2 = await (ToolsInteract as any).waitForUIHandler({ type: 'ui', query: 'Boundary', timeoutMs: 2000, pollIntervalMs: 100, stability_ms: 300, match: 'present', platform: 'android' })
|
|
21
21
|
console.log('Boundary stability test:', r2 && (r2 as any).success ? 'PASS' : 'FAIL', JSON.stringify({ poll_count: (r2 as any).poll_count, duration_ms: (r2 as any).duration_ms, stable_duration_ms: (r2 as any).stable_duration_ms, matchSource: (r2 as any).matchSource }, null, 2))
|
|
22
22
|
|
|
23
23
|
// 3) Long flicker that never stabilizes should timeout/fail
|
|
24
24
|
// Sequence toggles true/false repeatedly
|
|
25
25
|
let seq3 = [false, true, false, true, false, true, false]
|
|
26
26
|
(ToolsInteract as any).findElementHandler = async () => ({ found: seq3.shift() ?? false })
|
|
27
|
-
const r3 = await (ToolsInteract as any).
|
|
27
|
+
const r3 = await (ToolsInteract as any).waitForUIHandler({ type: 'ui', query: 'Flicker', timeoutMs: 1200, pollIntervalMs: 150, stability_ms: 400, match: 'present', platform: 'android' })
|
|
28
28
|
console.log('Long flicker timeout test:', !(r3 && (r3 as any).success) ? 'PASS' : 'FAIL', JSON.stringify({ poll_count: (r3 as any).poll_count, duration_ms: (r3 as any).duration_ms, stable_duration_ms: (r3 as any).stable_duration_ms, matchSource: (r3 as any).matchSource }, null, 2))
|
|
29
29
|
|
|
30
30
|
// 4) Very short stability requirement should pass quickly
|
|
31
31
|
(ToolsInteract as any).findElementHandler = async () => ({ found: true })
|
|
32
|
-
const r4 = await (ToolsInteract as any).
|
|
32
|
+
const r4 = await (ToolsInteract as any).waitForUIHandler({ type: 'ui', query: 'ShortStable', timeoutMs: 2000, pollIntervalMs: 200, stability_ms: 50, match: 'present', platform: 'android' })
|
|
33
33
|
console.log('Short stability test:', r4 && (r4 as any).success ? 'PASS' : 'FAIL', JSON.stringify({ poll_count: (r4 as any).poll_count, duration_ms: (r4 as any).duration_ms, stable_duration_ms: (r4 as any).stable_duration_ms, matchSource: (r4 as any).matchSource }, null, 2))
|
|
34
34
|
|
|
35
35
|
} finally {
|
|
@@ -2,7 +2,7 @@ import { ToolsInteract } from '../../../../src/interact/index.js'
|
|
|
2
2
|
import * as Observe from '../../../../src/observe/index.js'
|
|
3
3
|
|
|
4
4
|
async function run() {
|
|
5
|
-
console.log('Unit:
|
|
5
|
+
console.log('Unit: wait_for_ui stability behavior')
|
|
6
6
|
|
|
7
7
|
const origFind = (ToolsInteract as any).findElementHandler
|
|
8
8
|
const origFp = (Observe as any).ToolsObserve.getScreenFingerprintHandler
|
|
@@ -12,13 +12,13 @@ async function run() {
|
|
|
12
12
|
const seq = [false, true, false, true, true, true]
|
|
13
13
|
(ToolsInteract as any).findElementHandler = async () => ({ found: seq.shift() ?? true })
|
|
14
14
|
|
|
15
|
-
const res = await (ToolsInteract as any).
|
|
15
|
+
const res = await (ToolsInteract as any).waitForUIHandler({ type: 'ui', query: 'X', timeoutMs: 5000, pollIntervalMs: 100, stability_ms: 500, platform: 'android' })
|
|
16
16
|
const ok = res && (res as any).success
|
|
17
17
|
console.log('Flicker stability test:', ok ? 'PASS' : 'FAIL', JSON.stringify((res as any).telemetry || {}, null, 2))
|
|
18
18
|
|
|
19
19
|
// Simulate immediate stable presence
|
|
20
20
|
(ToolsInteract as any).findElementHandler = async () => ({ found: true })
|
|
21
|
-
const res2 = await (ToolsInteract as any).
|
|
21
|
+
const res2 = await (ToolsInteract as any).waitForUIHandler({ type: 'ui', query: 'Y', timeoutMs: 2000, pollIntervalMs: 100, stability_ms: 300, platform: 'android' })
|
|
22
22
|
console.log('Immediate stable test:', res2 && (res2 as any).success ? 'PASS' : 'FAIL', JSON.stringify((res2 as any).telemetry || {}, null, 2))
|
|
23
23
|
|
|
24
24
|
} finally {
|
package/test/unit/index.ts
CHANGED
|
@@ -1,18 +1,30 @@
|
|
|
1
1
|
// Aggregator entrypoint for unit tests (updated to new test layout)
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
import
|
|
5
|
-
import
|
|
2
|
+
(async function() {
|
|
3
|
+
// Core unit tests that do not require real devices
|
|
4
|
+
await import('../observe/unit/logparse.test.ts')
|
|
5
|
+
await import('../observe/unit/logstream.test.ts')
|
|
6
|
+
await import('../observe/unit/get_screen_fingerprint.test.ts')
|
|
6
7
|
|
|
7
|
-
import
|
|
8
|
-
import
|
|
9
|
-
import
|
|
10
|
-
import
|
|
11
|
-
import
|
|
12
|
-
import
|
|
13
|
-
import
|
|
14
|
-
import '../observe/unit/capture_debug_snapshot.test.ts'
|
|
15
|
-
import '../observe/unit/find_element.test.ts'
|
|
16
|
-
import '../interact/unit/observe_until.test.ts'
|
|
8
|
+
await import('../manage/unit/install.test.ts')
|
|
9
|
+
await import('../manage/unit/build.test.ts')
|
|
10
|
+
await import('../manage/unit/build_and_install.test.ts')
|
|
11
|
+
await import('../manage/unit/diagnostics.test.ts')
|
|
12
|
+
await import('../manage/unit/detection.test.ts')
|
|
13
|
+
await import('../manage/unit/mcp_disable_autodetect.test.ts')
|
|
14
|
+
await import('../interact/unit/wait_for_screen_change.test.ts')
|
|
17
15
|
|
|
18
|
-
|
|
16
|
+
// Conditionally include device-dependent unit tests. Set SKIP_DEVICE_TESTS=1 to exclude.
|
|
17
|
+
if (process.env.SKIP_DEVICE_TESTS !== '1') {
|
|
18
|
+
try {
|
|
19
|
+
await import('../observe/unit/capture_debug_snapshot.test.ts')
|
|
20
|
+
await import('../observe/unit/find_element.test.ts')
|
|
21
|
+
await import('../interact/unit/wait_for_ui.test.ts')
|
|
22
|
+
} catch (e) {
|
|
23
|
+
console.warn('Skipping some device-dependent tests due to import error:', e instanceof Error ? e.message : String(e))
|
|
24
|
+
}
|
|
25
|
+
} else {
|
|
26
|
+
console.log('SKIP_DEVICE_TESTS=1 detected - skipping device-dependent unit tests')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
console.log('Unit tests loaded.')
|
|
30
|
+
})().catch(e => { console.error(e); process.exit(1) })
|