@astur-mobile/cli 0.1.0-beta.4 → 0.3.0-beta.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/README.md +16 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/dist/inspectorServer.d.ts +11 -1
- package/dist/inspectorServer.d.ts.map +1 -1
- package/dist/inspectorServer.js +326 -42
- package/dist/inspectorServer.js.map +1 -1
- package/dist/inspectorUi.js +24 -0
- package/dist/inspectorUi.js.map +1 -1
- package/dist/scaffold.js +18 -5
- package/dist/scaffold.js.map +1 -1
- package/package.json +5 -5
package/dist/inspectorServer.js
CHANGED
|
@@ -35,6 +35,73 @@ export function startInspectorServer(inspector, device, options) {
|
|
|
35
35
|
let gestureCommandInFlight = false;
|
|
36
36
|
let lastGestureCommandAt = 0;
|
|
37
37
|
const bundleUploads = new Map();
|
|
38
|
+
// ── WebView DOM probe ──────────────────────────────────────────────────────
|
|
39
|
+
// Pulling a WebView's DOM means opening a remote-debugging transport, which is
|
|
40
|
+
// far heavier than a native tree read — so probe it on its own adaptive cadence
|
|
41
|
+
// (fast while a WebView is present, slow while absent) and cache the raw DOM.
|
|
42
|
+
// Each native tree push re-splices the cached DOM against the *current* native
|
|
43
|
+
// nodes (cheap, in-memory) so coordinates track the live host node.
|
|
44
|
+
let cachedWebTree;
|
|
45
|
+
let webProbeNextAt = 0;
|
|
46
|
+
let webProbeInFlight = false;
|
|
47
|
+
const WEB_PROBE_PRESENT_MS = 1_500;
|
|
48
|
+
const WEB_PROBE_ABSENT_MS = 6_000;
|
|
49
|
+
// ── Device-action scheduler ────────────────────────────────────────────
|
|
50
|
+
// Serialize device actions ahead of the background screenshot/tree/WebView
|
|
51
|
+
// polls. While a fill/tap/swipe/etc. runs, polling is paused so it cannot
|
|
52
|
+
// pile commands onto the single-threaded agent, re-focus the field mid-type
|
|
53
|
+
// (the "autofill tag keeps reappearing" retry feel), or dispose the WebView
|
|
54
|
+
// context between two fills. After the last action settles, refresh once.
|
|
55
|
+
let pendingDeviceActions = 0;
|
|
56
|
+
let actionSettleTimer;
|
|
57
|
+
const ACTION_SETTLE_MS = 350;
|
|
58
|
+
const devicePollingPaused = () => pendingDeviceActions > 0 || actionSettleTimer !== undefined;
|
|
59
|
+
async function runDeviceAction(fn) {
|
|
60
|
+
pendingDeviceActions += 1;
|
|
61
|
+
if (actionSettleTimer) {
|
|
62
|
+
clearTimeout(actionSettleTimer);
|
|
63
|
+
actionSettleTimer = undefined;
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
return await fn();
|
|
67
|
+
}
|
|
68
|
+
finally {
|
|
69
|
+
pendingDeviceActions -= 1;
|
|
70
|
+
if (pendingDeviceActions === 0) {
|
|
71
|
+
// Let the keyboard/scroll animation finish, then do a single refresh
|
|
72
|
+
// (one tree+frame read) instead of many mid-action reads.
|
|
73
|
+
actionSettleTimer = setTimeout(() => {
|
|
74
|
+
actionSettleTimer = undefined;
|
|
75
|
+
void syncInspectorState();
|
|
76
|
+
}, ACTION_SETTLE_MS);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function maybeProbeWebTree() {
|
|
81
|
+
if (!activeInspector.webSnapshot || webProbeInFlight || Date.now() < webProbeNextAt) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
webProbeInFlight = true;
|
|
85
|
+
void (async () => {
|
|
86
|
+
try {
|
|
87
|
+
const web = await activeInspector.webSnapshot?.();
|
|
88
|
+
const had = Boolean(cachedWebTree);
|
|
89
|
+
cachedWebTree = web ?? undefined;
|
|
90
|
+
webProbeNextAt = Date.now() + (cachedWebTree ? WEB_PROBE_PRESENT_MS : WEB_PROBE_ABSENT_MS);
|
|
91
|
+
// Surface a newly found (or newly lost) WebView DOM promptly.
|
|
92
|
+
if (Boolean(cachedWebTree) !== had) {
|
|
93
|
+
void pushTree({ reportFirstError: false }).catch(() => undefined);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
cachedWebTree = undefined;
|
|
98
|
+
webProbeNextAt = Date.now() + WEB_PROBE_ABSENT_MS;
|
|
99
|
+
}
|
|
100
|
+
finally {
|
|
101
|
+
webProbeInFlight = false;
|
|
102
|
+
}
|
|
103
|
+
})();
|
|
104
|
+
}
|
|
38
105
|
// ── HTTP server ────────────────────────────────────────────────────────
|
|
39
106
|
const server = createServer((req, res) => {
|
|
40
107
|
const url = req.url ?? '/';
|
|
@@ -348,16 +415,18 @@ export function startInspectorServer(inspector, device, options) {
|
|
|
348
415
|
}
|
|
349
416
|
if (shouldRecordTap && suggestions[0]) {
|
|
350
417
|
try {
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
418
|
+
await runDeviceAction(async () => {
|
|
419
|
+
if (options.performTap) {
|
|
420
|
+
await options.performTap({ x: event.x, y: event.y });
|
|
421
|
+
}
|
|
422
|
+
else {
|
|
423
|
+
await activeInspector.executeAction({
|
|
424
|
+
kind: 'tap',
|
|
425
|
+
selector: suggestions[0].selector,
|
|
426
|
+
options: { timeout: 2_000 }
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
});
|
|
361
430
|
}
|
|
362
431
|
catch (error) {
|
|
363
432
|
ws.send(JSON.stringify({
|
|
@@ -373,30 +442,39 @@ export function startInspectorServer(inspector, device, options) {
|
|
|
373
442
|
};
|
|
374
443
|
steps.push(step);
|
|
375
444
|
broadcast({ type: 'step', ...step });
|
|
376
|
-
await syncInspectorState();
|
|
377
445
|
broadcast({ type: 'status', message: 'Action OK: Tap recorded' });
|
|
378
446
|
}
|
|
379
447
|
break;
|
|
380
448
|
}
|
|
381
449
|
case 'direct_action': {
|
|
450
|
+
// The fill/tap buttons act on the currently selected node. When that is a
|
|
451
|
+
// spliced WebView DOM node, drive it by its DOM locator (in-page JS)
|
|
452
|
+
// instead of the native driver.
|
|
453
|
+
const selectedWebNode = selectedUid
|
|
454
|
+
? currentNodes.find((node) => node.uid === selectedUid && node.web)
|
|
455
|
+
: undefined;
|
|
382
456
|
try {
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
457
|
+
await runDeviceAction(async () => {
|
|
458
|
+
if (selectedWebNode?.web && activeInspector.webAct) {
|
|
459
|
+
await activeInspector.webAct(selectedWebNode.web, event.action === 'fill' ? 'fill' : 'tap', event.value);
|
|
460
|
+
}
|
|
461
|
+
else if (event.action === 'fill') {
|
|
462
|
+
await activeInspector.executeAction({
|
|
463
|
+
kind: 'fill',
|
|
464
|
+
selector: event.selector,
|
|
465
|
+
value: event.value ?? '',
|
|
466
|
+
options: { timeout: 2_000 }
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
else {
|
|
470
|
+
await activeInspector.executeAction({
|
|
471
|
+
kind: 'tap',
|
|
472
|
+
selector: event.selector,
|
|
473
|
+
options: { timeout: 2_000 }
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
});
|
|
477
|
+
broadcast({ type: 'status', message: `Action OK: ${event.action === 'fill' ? 'Filled' : 'Tapped'}${selectedWebNode ? ' (web)' : ''}` });
|
|
400
478
|
}
|
|
401
479
|
catch (error) {
|
|
402
480
|
ws.send(JSON.stringify({
|
|
@@ -491,6 +569,29 @@ export function startInspectorServer(inspector, device, options) {
|
|
|
491
569
|
setTimeout(() => process.exit(0), 300);
|
|
492
570
|
break;
|
|
493
571
|
}
|
|
572
|
+
case 'release_session': {
|
|
573
|
+
if (!options.releaseSession) {
|
|
574
|
+
ws.send(JSON.stringify({ type: 'status', message: 'Action Error: Release is unavailable in this session.' }));
|
|
575
|
+
break;
|
|
576
|
+
}
|
|
577
|
+
broadcast({ type: 'status', message: 'Action Pending: Releasing session...' });
|
|
578
|
+
try {
|
|
579
|
+
await options.releaseSession();
|
|
580
|
+
}
|
|
581
|
+
catch (error) {
|
|
582
|
+
broadcast({
|
|
583
|
+
type: 'status',
|
|
584
|
+
message: `Action Error: Release failed: ${formatActionError(error)}`
|
|
585
|
+
});
|
|
586
|
+
break;
|
|
587
|
+
}
|
|
588
|
+
broadcast({
|
|
589
|
+
type: 'terminated',
|
|
590
|
+
message: 'Session released. The device session was closed; the emulator/simulator was left running. You can close this tab.'
|
|
591
|
+
});
|
|
592
|
+
setTimeout(() => process.exit(0), 300);
|
|
593
|
+
break;
|
|
594
|
+
}
|
|
494
595
|
case 'app_action': {
|
|
495
596
|
if (!options.performAppAction) {
|
|
496
597
|
ws.send(JSON.stringify({ type: 'status', message: 'Action Error: App actions are unavailable in this session.' }));
|
|
@@ -689,7 +790,7 @@ export function startInspectorServer(inspector, device, options) {
|
|
|
689
790
|
let frameTimer;
|
|
690
791
|
let frameInFlight = false;
|
|
691
792
|
async function pushFrame() {
|
|
692
|
-
if (clients.size === 0 || frameInFlight) {
|
|
793
|
+
if (clients.size === 0 || frameInFlight || devicePollingPaused()) {
|
|
693
794
|
return 'busy';
|
|
694
795
|
}
|
|
695
796
|
frameInFlight = true;
|
|
@@ -740,7 +841,9 @@ export function startInspectorServer(inspector, device, options) {
|
|
|
740
841
|
let lastTreeErrorAt = 0;
|
|
741
842
|
let consecutiveTreeErrors = 0;
|
|
742
843
|
async function pushTree(options = {}) {
|
|
743
|
-
|
|
844
|
+
// Bootstrap/manual refreshes (reportFirstError) bypass the action gate;
|
|
845
|
+
// background polls pause while a device action is running or settling.
|
|
846
|
+
if (treeInFlight || (devicePollingPaused() && !options.reportFirstError)) {
|
|
744
847
|
return 'busy';
|
|
745
848
|
}
|
|
746
849
|
// Avoid expensive UI-tree reads (e.g. adb `uiautomator dump`, broad XCUI
|
|
@@ -753,6 +856,17 @@ export function startInspectorServer(inspector, device, options) {
|
|
|
753
856
|
try {
|
|
754
857
|
for await (const update of activeInspector.subscribeTree({ maxUpdates: 1 })) {
|
|
755
858
|
const nodes = flattenSnapshot(update.root);
|
|
859
|
+
// Splice any WebView DOM (cached from the adaptive probe) under its native
|
|
860
|
+
// host so its elements appear in the tree with real DOM locators.
|
|
861
|
+
maybeProbeWebTree();
|
|
862
|
+
if (cachedWebTree) {
|
|
863
|
+
try {
|
|
864
|
+
nodes.push(...buildWebNodes(cachedWebTree, nodes));
|
|
865
|
+
}
|
|
866
|
+
catch {
|
|
867
|
+
// Best-effort: never let WebView DOM block the native tree.
|
|
868
|
+
}
|
|
869
|
+
}
|
|
756
870
|
const viewport = estimateViewport(nodes);
|
|
757
871
|
currentNodes = nodes;
|
|
758
872
|
currentViewport = viewport;
|
|
@@ -813,7 +927,12 @@ export function startInspectorServer(inspector, device, options) {
|
|
|
813
927
|
return { frame, tree: 'busy' };
|
|
814
928
|
}
|
|
815
929
|
function scheduleTree() {
|
|
816
|
-
|
|
930
|
+
// Back off hard when the tree read keeps failing/timing out — e.g. XCUITest
|
|
931
|
+
// stalling on a heavy WebView snapshot. Retrying every interval just piles
|
|
932
|
+
// commands onto an already-stuck agent; poll slowly until it recovers (a
|
|
933
|
+
// successful read resets consecutiveTreeErrors and snaps back to base).
|
|
934
|
+
const stalled = consecutiveTreeErrors >= 3;
|
|
935
|
+
const interval = clients.size === 0 || stalled
|
|
817
936
|
? maxIdleIntervalMs
|
|
818
937
|
: nextPollInterval(baseTreeIntervalMs, treeIdleStreak, maxIdleIntervalMs);
|
|
819
938
|
treeTimer = setTimeout(async () => {
|
|
@@ -929,6 +1048,98 @@ function flattenNode(node, depth, uid, parentUid) {
|
|
|
929
1048
|
bounds: node.bounds,
|
|
930
1049
|
};
|
|
931
1050
|
}
|
|
1051
|
+
function webLocatorCode(descriptor) {
|
|
1052
|
+
switch (descriptor.strategy) {
|
|
1053
|
+
case 'testid': return `device.getByTestId(${JSON.stringify(descriptor.value)})`;
|
|
1054
|
+
case 'id': return `device.getById(${JSON.stringify(descriptor.value)})`;
|
|
1055
|
+
case 'role': return `device.getByRole(${JSON.stringify(descriptor.value)}${descriptor.name ? `, { name: ${JSON.stringify(descriptor.name)} }` : ''})`;
|
|
1056
|
+
case 'text': return `device.getByText(${JSON.stringify(descriptor.value)})`;
|
|
1057
|
+
default: return `device.locator(${JSON.stringify(descriptor.value)})`;
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
function webLocatorSuggestion(descriptor) {
|
|
1061
|
+
const code = webLocatorCode(descriptor);
|
|
1062
|
+
// Web actions are routed by the DOM locator (node.web), not this selector, so it
|
|
1063
|
+
// only needs to be a valid placeholder for display/typing.
|
|
1064
|
+
const selector = descriptor.strategy === 'role' ? { strategy: 'role', value: descriptor.value, name: descriptor.name }
|
|
1065
|
+
: descriptor.strategy === 'text' ? { strategy: 'text', value: descriptor.value }
|
|
1066
|
+
: { strategy: 'id', value: descriptor.value };
|
|
1067
|
+
return { code, selector, score: 0.96, uniqueness: 1, stability: 0.9, readable: code.length <= 88, crossPlatform: true };
|
|
1068
|
+
}
|
|
1069
|
+
function webNodeTitle(node) {
|
|
1070
|
+
const tag = node.tag.toLowerCase();
|
|
1071
|
+
const handle = node.testId ? `[testid=${node.testId}]` : node.id ? `#${node.id}` : '';
|
|
1072
|
+
const name = (node.name ?? '').replace(/\s+/g, ' ').trim().slice(0, 40);
|
|
1073
|
+
return `<${tag}>${handle ? ` ${handle}` : ''}${name ? ` ${name}` : ''}`.trim();
|
|
1074
|
+
}
|
|
1075
|
+
/**
|
|
1076
|
+
* Locate the native node that hosts the WebView surface: the one whose device-px
|
|
1077
|
+
* bounds best match the web viewport scaled by the device pixel ratio. Used to
|
|
1078
|
+
* map DOM (CSS px) coordinates onto the device screen for overlays/hit-testing.
|
|
1079
|
+
*/
|
|
1080
|
+
function findWebHostNode(web, nativeNodes) {
|
|
1081
|
+
const dpr = web.devicePixelRatio || 1;
|
|
1082
|
+
const targetW = web.viewport.width * dpr;
|
|
1083
|
+
const targetH = web.viewport.height * dpr;
|
|
1084
|
+
if (targetW < 8 || targetH < 8) {
|
|
1085
|
+
return undefined;
|
|
1086
|
+
}
|
|
1087
|
+
let best;
|
|
1088
|
+
let bestScore = Number.POSITIVE_INFINITY;
|
|
1089
|
+
for (const node of nativeNodes) {
|
|
1090
|
+
if (!node.visible || node.bounds.width < 8 || node.bounds.height < 8) {
|
|
1091
|
+
continue;
|
|
1092
|
+
}
|
|
1093
|
+
const dw = Math.abs(node.bounds.width - targetW);
|
|
1094
|
+
const dh = Math.abs(node.bounds.height - targetH);
|
|
1095
|
+
if (dw <= targetW * 0.25 && dh <= targetH * 0.25 && dw + dh < bestScore) {
|
|
1096
|
+
best = node;
|
|
1097
|
+
bestScore = dw + dh;
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
return best;
|
|
1101
|
+
}
|
|
1102
|
+
/**
|
|
1103
|
+
* Flatten a WebView DOM snapshot into UiNodes spliced under its native host. DOM
|
|
1104
|
+
* actions run by locator (engine JS), so even when the host can't be matched the
|
|
1105
|
+
* nodes stay browsable/fillable — only the screenshot overlay needs the bounds.
|
|
1106
|
+
*/
|
|
1107
|
+
function buildWebNodes(web, nativeNodes) {
|
|
1108
|
+
if (!web.root) {
|
|
1109
|
+
return [];
|
|
1110
|
+
}
|
|
1111
|
+
const host = findWebHostNode(web, nativeNodes);
|
|
1112
|
+
const dpr = web.devicePixelRatio || 1;
|
|
1113
|
+
const mapBounds = (b) => host
|
|
1114
|
+
? {
|
|
1115
|
+
x: Math.round(host.bounds.x + b.x * dpr),
|
|
1116
|
+
y: Math.round(host.bounds.y + b.y * dpr),
|
|
1117
|
+
width: Math.round(b.width * dpr),
|
|
1118
|
+
height: Math.round(b.height * dpr)
|
|
1119
|
+
}
|
|
1120
|
+
: { x: 0, y: 0, width: 0, height: 0 };
|
|
1121
|
+
const out = [];
|
|
1122
|
+
const visit = (node, depth, uid, parentUid) => {
|
|
1123
|
+
out.push({
|
|
1124
|
+
uid,
|
|
1125
|
+
parentUid,
|
|
1126
|
+
depth,
|
|
1127
|
+
title: webNodeTitle(node),
|
|
1128
|
+
type: `web:${node.tag.toLowerCase()}`,
|
|
1129
|
+
id: node.id,
|
|
1130
|
+
label: node.name,
|
|
1131
|
+
text: node.role ? undefined : node.name,
|
|
1132
|
+
value: node.value,
|
|
1133
|
+
visible: node.visible,
|
|
1134
|
+
enabled: node.enabled,
|
|
1135
|
+
bounds: mapBounds(node.bounds),
|
|
1136
|
+
web: node.locator
|
|
1137
|
+
});
|
|
1138
|
+
node.children.forEach((child, index) => visit(child, depth + 1, `${uid}/${index}`, uid));
|
|
1139
|
+
};
|
|
1140
|
+
visit(web.root, host ? host.depth + 1 : 1, 'web', host?.uid);
|
|
1141
|
+
return out;
|
|
1142
|
+
}
|
|
932
1143
|
function estimateViewport(nodes) {
|
|
933
1144
|
const root = nodes[0];
|
|
934
1145
|
if (root?.bounds && root.bounds.width > 0 && root.bounds.height > 0) {
|
|
@@ -995,6 +1206,10 @@ const INSPECTOR_ROLES = [
|
|
|
995
1206
|
'textbox'
|
|
996
1207
|
];
|
|
997
1208
|
function suggestLocatorsForNode(node, nodes) {
|
|
1209
|
+
if (node.web) {
|
|
1210
|
+
// DOM nodes carry the in-page best locator; the native ranking doesn't apply.
|
|
1211
|
+
return [webLocatorSuggestion(node.web)];
|
|
1212
|
+
}
|
|
998
1213
|
const target = resolveInspectableNode(node, nodes);
|
|
999
1214
|
const candidates = buildLocalLocatorCandidates(target);
|
|
1000
1215
|
const suggestions = [];
|
|
@@ -1093,6 +1308,11 @@ function buildLocalLocatorCandidates(node) {
|
|
|
1093
1308
|
return candidates;
|
|
1094
1309
|
}
|
|
1095
1310
|
function resolveInspectableNode(node, nodes, options = {}) {
|
|
1311
|
+
// WebView DOM nodes are already their own best target — never re-resolve them to
|
|
1312
|
+
// a native ancestor, or selection/action routing would lose the DOM locator.
|
|
1313
|
+
if (node.web) {
|
|
1314
|
+
return node;
|
|
1315
|
+
}
|
|
1096
1316
|
const targetIsUsable = hasUsableLocator(node) && !isDecorativeNode(node);
|
|
1097
1317
|
const targetIsActionable = targetIsUsable && isActionableNode(node);
|
|
1098
1318
|
if (!options.preferActionable && targetIsUsable) {
|
|
@@ -1770,10 +1990,11 @@ html,body{height:100%;overflow:hidden;background:var(--bg);color:var(--text);fon
|
|
|
1770
1990
|
${renderInspectorDeviceActionMenu(device)}
|
|
1771
1991
|
<div class="device-menu-section">
|
|
1772
1992
|
<div class="device-menu-label">Session</div>
|
|
1773
|
-
<div class="device-
|
|
1774
|
-
<button type="button" class="device-action-btn
|
|
1993
|
+
<div class="device-menu-actions">
|
|
1994
|
+
<button type="button" class="device-action-btn icon" id="release-session-btn" title="Release session" aria-label="Release session">${iconSvg('<path d="M5 17h14"/><path d="M12 4 5 13h14z"/>')}</button>
|
|
1995
|
+
<button type="button" class="device-action-btn icon danger" id="terminate-session-btn" title="Terminate session" aria-label="Terminate session">${iconSvg('<path d="M12 2v10"/><path d="M18.4 6.6a9 9 0 1 1-12.8 0"/>')}</button>
|
|
1775
1996
|
</div>
|
|
1776
|
-
<div class="device-help">
|
|
1997
|
+
<div class="device-help"><strong>Release</strong> closes the device session but leaves the emulator/simulator running for reuse. <strong>Terminate</strong> also powers it off to free memory. Real devices stay on.</div>
|
|
1777
1998
|
</div>
|
|
1778
1999
|
</div>
|
|
1779
2000
|
</div>
|
|
@@ -1842,7 +2063,11 @@ html,body{height:100%;overflow:hidden;background:var(--bg);color:var(--text);fon
|
|
|
1842
2063
|
<div id="right-panel">
|
|
1843
2064
|
<!-- Tree -->
|
|
1844
2065
|
<div id="tree-panel">
|
|
1845
|
-
<div class="panel-header"
|
|
2066
|
+
<div class="panel-header" style="display:flex;align-items:center;gap:8px">
|
|
2067
|
+
<span>UI Tree</span>
|
|
2068
|
+
<span id="tree-badge" style="font-weight:400;color:var(--text-muted)"></span>
|
|
2069
|
+
<button type="button" class="device-action-btn icon" id="tree-refresh-btn" title="Refresh UI tree" aria-label="Refresh UI tree" style="margin-left:auto;width:26px;height:24px">${inspectorDeviceActionIcon('tree.refresh')}</button>
|
|
2070
|
+
</div>
|
|
1846
2071
|
<div id="tree-search-row">
|
|
1847
2072
|
<input id="tree-search" type="search" placeholder="Search element…"/>
|
|
1848
2073
|
</div>
|
|
@@ -1919,6 +2144,20 @@ let activeTab = 'code';
|
|
|
1919
2144
|
let currentDevice = ${JSON.stringify(toBootstrapDevice(device))};
|
|
1920
2145
|
let devices = [currentDevice];
|
|
1921
2146
|
let busyCount = 0;
|
|
2147
|
+
let busyWatchdog = null;
|
|
2148
|
+
// Hard ceiling on how long the "Running action…" overlay may stay up. If the
|
|
2149
|
+
// device/agent stops responding (e.g. XCUITest stalls while snapshotting a heavy
|
|
2150
|
+
// WebView), no status ever comes back and the overlay would otherwise spin
|
|
2151
|
+
// forever, bricking the session. The watchdog force-clears it so the user can
|
|
2152
|
+
// keep working (switch screens, terminate, etc.).
|
|
2153
|
+
//
|
|
2154
|
+
// Must sit ABOVE the native agent command timeout (iOS default 30s) — otherwise a
|
|
2155
|
+
// slow-but-succeeding action gets a premature, misleading "timed out" while the
|
|
2156
|
+
// agent is still completing it (e.g. filling a field inside a never-idle WKWebView,
|
|
2157
|
+
// where each keystroke's bounded idle-wait makes a clear+type land around ~16s). At
|
|
2158
|
+
// 35s the agent's own success/timeout response always arrives first, so the user
|
|
2159
|
+
// sees the real outcome; this stays purely a backstop for a truly hung session.
|
|
2160
|
+
const BUSY_WATCHDOG_MS = 35000;
|
|
1922
2161
|
let socketConnected = false;
|
|
1923
2162
|
let hasFrame = false;
|
|
1924
2163
|
let hasTree = false;
|
|
@@ -1990,6 +2229,8 @@ const revokePermissionBtn = $('revoke-permission-btn');
|
|
|
1990
2229
|
const clearDataBtn = $('clear-data-btn');
|
|
1991
2230
|
const clearCacheBtn = $('clear-cache-btn');
|
|
1992
2231
|
const terminateSessionBtn = $('terminate-session-btn');
|
|
2232
|
+
const releaseSessionBtn = $('release-session-btn');
|
|
2233
|
+
const treeRefreshBtn = $('tree-refresh-btn');
|
|
1993
2234
|
const main = $('main');
|
|
1994
2235
|
const leftColumnSplitter = $('left-column-splitter');
|
|
1995
2236
|
const rightColumnSplitter = $('right-column-splitter');
|
|
@@ -2194,6 +2435,25 @@ function toggleDeviceSwitcher() {
|
|
|
2194
2435
|
|
|
2195
2436
|
function setBusy(active) {
|
|
2196
2437
|
busyCount = Math.max(0, busyCount + (active ? 1 : -1));
|
|
2438
|
+
if (busyWatchdog) {
|
|
2439
|
+
clearTimeout(busyWatchdog);
|
|
2440
|
+
busyWatchdog = null;
|
|
2441
|
+
}
|
|
2442
|
+
if (busyCount > 0) {
|
|
2443
|
+
busyWatchdog = setTimeout(onBusyWatchdogExpired, BUSY_WATCHDOG_MS);
|
|
2444
|
+
}
|
|
2445
|
+
updateDeviceReadiness();
|
|
2446
|
+
}
|
|
2447
|
+
|
|
2448
|
+
// Fires when an action's overlay has been up too long with no response. Clears
|
|
2449
|
+
// the overlay and tells the user the session is still live so they can recover
|
|
2450
|
+
// (common when a native+web hybrid screen leaves XCUITest mid-snapshot).
|
|
2451
|
+
function onBusyWatchdogExpired() {
|
|
2452
|
+
busyWatchdog = null;
|
|
2453
|
+
if (busyCount === 0) return;
|
|
2454
|
+
busyCount = 0;
|
|
2455
|
+
releaseGestureLock();
|
|
2456
|
+
showDeviceStatus('Action timed out — the device is slow to respond (a heavy web view can stall the UI tree). The session is still live; try again or switch screens.', 'error');
|
|
2197
2457
|
updateDeviceReadiness();
|
|
2198
2458
|
}
|
|
2199
2459
|
|
|
@@ -2755,6 +3015,10 @@ function nodeRoles(node) {
|
|
|
2755
3015
|
if (type.includes('switch')) roles.push('switch');
|
|
2756
3016
|
if (type.includes('tab')) roles.push('tab');
|
|
2757
3017
|
if (type.includes('edittext') || type.includes('textfield') || type.includes('securetextfield') || type.includes('searchfield') || type.includes('textinput')) roles.push('textbox');
|
|
3018
|
+
// Spliced WebView DOM nodes ('web:input', 'web:textarea'): text inputs are fillable
|
|
3019
|
+
// (and contenteditable surfaces). Without this the Fill control stays disabled for
|
|
3020
|
+
// web-context elements even though the server routes them through webAct.
|
|
3021
|
+
if (type === 'web:input' || type === 'web:textarea' || type.includes('contenteditable')) roles.push('textbox');
|
|
2758
3022
|
return roles;
|
|
2759
3023
|
}
|
|
2760
3024
|
|
|
@@ -2814,18 +3078,25 @@ function renderDetails(node) {
|
|
|
2814
3078
|
|
|
2815
3079
|
// ── Mirror click ───────────────────────────────────────────────────────────
|
|
2816
3080
|
function resolveMirrorSourceSize() {
|
|
2817
|
-
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
3081
|
+
// Size the stage to the screenshot's own pixels first: those are exactly what
|
|
3082
|
+
// gets drawn, and the image is sized with explicit width/height (no
|
|
3083
|
+
// object-fit), so any aspect-ratio mismatch stretches it. On rotation the
|
|
3084
|
+
// landscape frame arrives a tick before the tree's viewport updates — if we
|
|
3085
|
+
// trusted the (still portrait) viewport here, the landscape image would be
|
|
3086
|
+
// squeezed into a portrait stage. Fall back to the viewport, then a square,
|
|
3087
|
+
// until the first frame has loaded.
|
|
2823
3088
|
const naturalWidth = Number(mirrorImg.naturalWidth || 0);
|
|
2824
3089
|
const naturalHeight = Number(mirrorImg.naturalHeight || 0);
|
|
2825
3090
|
if (naturalWidth > 1 && naturalHeight > 1) {
|
|
2826
3091
|
return { width: naturalWidth, height: naturalHeight };
|
|
2827
3092
|
}
|
|
2828
3093
|
|
|
3094
|
+
const viewportWidth = Number(viewport.width || 0);
|
|
3095
|
+
const viewportHeight = Number(viewport.height || 0);
|
|
3096
|
+
if (viewportWidth > 1 && viewportHeight > 1) {
|
|
3097
|
+
return { width: viewportWidth, height: viewportHeight };
|
|
3098
|
+
}
|
|
3099
|
+
|
|
2829
3100
|
return { width: 1, height: 1 };
|
|
2830
3101
|
}
|
|
2831
3102
|
|
|
@@ -3524,10 +3795,23 @@ terminateSessionBtn.addEventListener('click', () => {
|
|
|
3524
3795
|
if (!confirm('Terminate this inspector session?\\n\\nThe device session will be closed and any emulator or simulator will be powered off. Real devices stay on.')) return;
|
|
3525
3796
|
closeDeviceMenu();
|
|
3526
3797
|
terminateSessionBtn.disabled = true;
|
|
3527
|
-
|
|
3798
|
+
releaseSessionBtn.disabled = true;
|
|
3528
3799
|
send({ type: 'terminate_session' });
|
|
3529
3800
|
});
|
|
3530
3801
|
|
|
3802
|
+
releaseSessionBtn.addEventListener('click', () => {
|
|
3803
|
+
if (!confirm('Release this inspector session?\\n\\nThe device session will be closed, but the emulator or simulator will stay running so it can be reused without a cold boot.')) return;
|
|
3804
|
+
closeDeviceMenu();
|
|
3805
|
+
releaseSessionBtn.disabled = true;
|
|
3806
|
+
terminateSessionBtn.disabled = true;
|
|
3807
|
+
send({ type: 'release_session' });
|
|
3808
|
+
});
|
|
3809
|
+
|
|
3810
|
+
treeRefreshBtn.addEventListener('click', () => {
|
|
3811
|
+
showDeviceStatus('Refreshing UI tree...', 'pending');
|
|
3812
|
+
send({ type: 'device_action', action: 'tree.refresh' });
|
|
3813
|
+
});
|
|
3814
|
+
|
|
3531
3815
|
document.addEventListener('click', () => {
|
|
3532
3816
|
closeDeviceMenu();
|
|
3533
3817
|
closeDeviceSwitcher();
|