@astur-mobile/cli 0.1.0-beta.3 → 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 +129 -17
- package/dist/index.js.map +1 -1
- package/dist/inspectorServer.d.ts +28 -1
- package/dist/inspectorServer.d.ts.map +1 -1
- package/dist/inspectorServer.js +399 -39
- 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({
|
|
@@ -451,7 +529,12 @@ export function startInspectorServer(inspector, device, options) {
|
|
|
451
529
|
logoDataUri
|
|
452
530
|
});
|
|
453
531
|
await syncInspectorState();
|
|
454
|
-
|
|
532
|
+
if (binding.notice) {
|
|
533
|
+
broadcast({ type: 'status', message: `Action Error: ${binding.notice}` });
|
|
534
|
+
}
|
|
535
|
+
else {
|
|
536
|
+
broadcast({ type: 'status', message: `Action OK: Switched to ${activeDevice.name}` });
|
|
537
|
+
}
|
|
455
538
|
}
|
|
456
539
|
catch (error) {
|
|
457
540
|
ws.send(JSON.stringify({
|
|
@@ -461,6 +544,54 @@ export function startInspectorServer(inspector, device, options) {
|
|
|
461
544
|
}
|
|
462
545
|
break;
|
|
463
546
|
}
|
|
547
|
+
case 'terminate_session': {
|
|
548
|
+
if (!options.terminateSession) {
|
|
549
|
+
ws.send(JSON.stringify({ type: 'status', message: 'Action Error: Terminate is unavailable in this session.' }));
|
|
550
|
+
break;
|
|
551
|
+
}
|
|
552
|
+
broadcast({ type: 'status', message: 'Action Pending: Terminating session...' });
|
|
553
|
+
try {
|
|
554
|
+
await options.terminateSession();
|
|
555
|
+
}
|
|
556
|
+
catch (error) {
|
|
557
|
+
broadcast({
|
|
558
|
+
type: 'status',
|
|
559
|
+
message: `Action Error: Terminate failed: ${formatActionError(error)}`
|
|
560
|
+
});
|
|
561
|
+
break;
|
|
562
|
+
}
|
|
563
|
+
broadcast({
|
|
564
|
+
type: 'terminated',
|
|
565
|
+
message: 'Session terminated. The device session was closed and any emulator/simulator was shut down. You can close this tab.'
|
|
566
|
+
});
|
|
567
|
+
// Let the terminated event flush to clients, then stop the CLI — the
|
|
568
|
+
// inspector cannot continue once its device session is gone.
|
|
569
|
+
setTimeout(() => process.exit(0), 300);
|
|
570
|
+
break;
|
|
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
|
+
}
|
|
464
595
|
case 'app_action': {
|
|
465
596
|
if (!options.performAppAction) {
|
|
466
597
|
ws.send(JSON.stringify({ type: 'status', message: 'Action Error: App actions are unavailable in this session.' }));
|
|
@@ -659,7 +790,7 @@ export function startInspectorServer(inspector, device, options) {
|
|
|
659
790
|
let frameTimer;
|
|
660
791
|
let frameInFlight = false;
|
|
661
792
|
async function pushFrame() {
|
|
662
|
-
if (clients.size === 0 || frameInFlight) {
|
|
793
|
+
if (clients.size === 0 || frameInFlight || devicePollingPaused()) {
|
|
663
794
|
return 'busy';
|
|
664
795
|
}
|
|
665
796
|
frameInFlight = true;
|
|
@@ -710,7 +841,9 @@ export function startInspectorServer(inspector, device, options) {
|
|
|
710
841
|
let lastTreeErrorAt = 0;
|
|
711
842
|
let consecutiveTreeErrors = 0;
|
|
712
843
|
async function pushTree(options = {}) {
|
|
713
|
-
|
|
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)) {
|
|
714
847
|
return 'busy';
|
|
715
848
|
}
|
|
716
849
|
// Avoid expensive UI-tree reads (e.g. adb `uiautomator dump`, broad XCUI
|
|
@@ -723,6 +856,17 @@ export function startInspectorServer(inspector, device, options) {
|
|
|
723
856
|
try {
|
|
724
857
|
for await (const update of activeInspector.subscribeTree({ maxUpdates: 1 })) {
|
|
725
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
|
+
}
|
|
726
870
|
const viewport = estimateViewport(nodes);
|
|
727
871
|
currentNodes = nodes;
|
|
728
872
|
currentViewport = viewport;
|
|
@@ -783,7 +927,12 @@ export function startInspectorServer(inspector, device, options) {
|
|
|
783
927
|
return { frame, tree: 'busy' };
|
|
784
928
|
}
|
|
785
929
|
function scheduleTree() {
|
|
786
|
-
|
|
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
|
|
787
936
|
? maxIdleIntervalMs
|
|
788
937
|
: nextPollInterval(baseTreeIntervalMs, treeIdleStreak, maxIdleIntervalMs);
|
|
789
938
|
treeTimer = setTimeout(async () => {
|
|
@@ -899,6 +1048,98 @@ function flattenNode(node, depth, uid, parentUid) {
|
|
|
899
1048
|
bounds: node.bounds,
|
|
900
1049
|
};
|
|
901
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
|
+
}
|
|
902
1143
|
function estimateViewport(nodes) {
|
|
903
1144
|
const root = nodes[0];
|
|
904
1145
|
if (root?.bounds && root.bounds.width > 0 && root.bounds.height > 0) {
|
|
@@ -965,6 +1206,10 @@ const INSPECTOR_ROLES = [
|
|
|
965
1206
|
'textbox'
|
|
966
1207
|
];
|
|
967
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
|
+
}
|
|
968
1213
|
const target = resolveInspectableNode(node, nodes);
|
|
969
1214
|
const candidates = buildLocalLocatorCandidates(target);
|
|
970
1215
|
const suggestions = [];
|
|
@@ -1063,6 +1308,11 @@ function buildLocalLocatorCandidates(node) {
|
|
|
1063
1308
|
return candidates;
|
|
1064
1309
|
}
|
|
1065
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
|
+
}
|
|
1066
1316
|
const targetIsUsable = hasUsableLocator(node) && !isDecorativeNode(node);
|
|
1067
1317
|
const targetIsActionable = targetIsUsable && isActionableNode(node);
|
|
1068
1318
|
if (!options.preferActionable && targetIsUsable) {
|
|
@@ -1557,6 +1807,13 @@ html,body{height:100%;overflow:hidden;background:var(--bg);color:var(--text);fon
|
|
|
1557
1807
|
.device-menu-actions{display:flex;flex-wrap:wrap;gap:6px}
|
|
1558
1808
|
.device-action-btn{padding:5px 10px;border-radius:var(--radius);border:1px solid var(--border);font-size:11px;font-weight:600;cursor:pointer;background:var(--surface2);color:var(--text)}
|
|
1559
1809
|
.device-action-btn:hover{border-color:var(--accent);color:var(--accent-hover)}
|
|
1810
|
+
.device-action-btn.danger{color:var(--red);border-color:#4a1414}
|
|
1811
|
+
.device-action-btn.danger:hover{border-color:var(--red);color:var(--red);background:rgba(248,81,73,.08)}
|
|
1812
|
+
.device-action-btn.danger:disabled{opacity:.55;cursor:not-allowed}
|
|
1813
|
+
#terminated-overlay{position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center;background:rgba(7,17,31,.82);backdrop-filter:blur(4px)}
|
|
1814
|
+
#terminated-overlay .terminated-card{max-width:420px;margin:24px;padding:28px 32px;border-radius:12px;border:1px solid var(--border);background:var(--surface);text-align:center;box-shadow:0 20px 60px rgba(0,0,0,.45)}
|
|
1815
|
+
#terminated-overlay h2{margin:0 0 10px;font-size:18px;color:var(--text)}
|
|
1816
|
+
#terminated-overlay p{margin:0;font-size:13px;line-height:1.5;color:var(--text-muted)}
|
|
1560
1817
|
.device-action-btn.icon{width:32px;height:30px;padding:0;display:inline-flex;align-items:center;justify-content:center}
|
|
1561
1818
|
.device-action-btn.icon svg{width:15px;height:15px;stroke:currentColor;stroke-width:2;fill:none;stroke-linecap:round;stroke-linejoin:round}
|
|
1562
1819
|
.device-row{display:flex;gap:6px;align-items:center}
|
|
@@ -1731,6 +1988,14 @@ html,body{height:100%;overflow:hidden;background:var(--bg);color:var(--text);fon
|
|
|
1731
1988
|
</div>
|
|
1732
1989
|
</div>
|
|
1733
1990
|
${renderInspectorDeviceActionMenu(device)}
|
|
1991
|
+
<div class="device-menu-section">
|
|
1992
|
+
<div class="device-menu-label">Session</div>
|
|
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>
|
|
1996
|
+
</div>
|
|
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>
|
|
1998
|
+
</div>
|
|
1734
1999
|
</div>
|
|
1735
2000
|
</div>
|
|
1736
2001
|
<button id="record-btn" title="Toggle recording">Record</button>
|
|
@@ -1798,7 +2063,11 @@ html,body{height:100%;overflow:hidden;background:var(--bg);color:var(--text);fon
|
|
|
1798
2063
|
<div id="right-panel">
|
|
1799
2064
|
<!-- Tree -->
|
|
1800
2065
|
<div id="tree-panel">
|
|
1801
|
-
<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>
|
|
1802
2071
|
<div id="tree-search-row">
|
|
1803
2072
|
<input id="tree-search" type="search" placeholder="Search element…"/>
|
|
1804
2073
|
</div>
|
|
@@ -1875,6 +2144,20 @@ let activeTab = 'code';
|
|
|
1875
2144
|
let currentDevice = ${JSON.stringify(toBootstrapDevice(device))};
|
|
1876
2145
|
let devices = [currentDevice];
|
|
1877
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;
|
|
1878
2161
|
let socketConnected = false;
|
|
1879
2162
|
let hasFrame = false;
|
|
1880
2163
|
let hasTree = false;
|
|
@@ -1945,6 +2228,9 @@ const grantPermissionBtn = $('grant-permission-btn');
|
|
|
1945
2228
|
const revokePermissionBtn = $('revoke-permission-btn');
|
|
1946
2229
|
const clearDataBtn = $('clear-data-btn');
|
|
1947
2230
|
const clearCacheBtn = $('clear-cache-btn');
|
|
2231
|
+
const terminateSessionBtn = $('terminate-session-btn');
|
|
2232
|
+
const releaseSessionBtn = $('release-session-btn');
|
|
2233
|
+
const treeRefreshBtn = $('tree-refresh-btn');
|
|
1948
2234
|
const main = $('main');
|
|
1949
2235
|
const leftColumnSplitter = $('left-column-splitter');
|
|
1950
2236
|
const rightColumnSplitter = $('right-column-splitter');
|
|
@@ -1980,6 +2266,25 @@ function send(obj) {
|
|
|
1980
2266
|
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(obj));
|
|
1981
2267
|
}
|
|
1982
2268
|
|
|
2269
|
+
function sessionTerminated(message) {
|
|
2270
|
+
// The server exits on purpose after terminating, so stop the auto-reconnect
|
|
2271
|
+
// loop (otherwise the socket close handler would spam "Reconnecting…").
|
|
2272
|
+
if (ws) { try { ws.onclose = null; ws.close(); } catch (e) { /* ignore */ } }
|
|
2273
|
+
if (document.getElementById('terminated-overlay')) return;
|
|
2274
|
+
const overlay = document.createElement('div');
|
|
2275
|
+
overlay.id = 'terminated-overlay';
|
|
2276
|
+
const card = document.createElement('div');
|
|
2277
|
+
card.className = 'terminated-card';
|
|
2278
|
+
const heading = document.createElement('h2');
|
|
2279
|
+
heading.textContent = 'Session terminated';
|
|
2280
|
+
const body = document.createElement('p');
|
|
2281
|
+
body.textContent = message;
|
|
2282
|
+
card.appendChild(heading);
|
|
2283
|
+
card.appendChild(body);
|
|
2284
|
+
overlay.appendChild(card);
|
|
2285
|
+
document.body.appendChild(overlay);
|
|
2286
|
+
}
|
|
2287
|
+
|
|
1983
2288
|
// ── Server event handler ───────────────────────────────────────────────────
|
|
1984
2289
|
function handleServerEvent(ev) {
|
|
1985
2290
|
switch (ev.type) {
|
|
@@ -2076,6 +2381,10 @@ function handleServerEvent(ev) {
|
|
|
2076
2381
|
setBusy(false);
|
|
2077
2382
|
break;
|
|
2078
2383
|
|
|
2384
|
+
case 'terminated':
|
|
2385
|
+
sessionTerminated(ev.message || 'Session terminated.');
|
|
2386
|
+
break;
|
|
2387
|
+
|
|
2079
2388
|
case 'gesture_ack':
|
|
2080
2389
|
releaseGestureLock();
|
|
2081
2390
|
setBusy(false);
|
|
@@ -2126,6 +2435,25 @@ function toggleDeviceSwitcher() {
|
|
|
2126
2435
|
|
|
2127
2436
|
function setBusy(active) {
|
|
2128
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');
|
|
2129
2457
|
updateDeviceReadiness();
|
|
2130
2458
|
}
|
|
2131
2459
|
|
|
@@ -2687,6 +3015,10 @@ function nodeRoles(node) {
|
|
|
2687
3015
|
if (type.includes('switch')) roles.push('switch');
|
|
2688
3016
|
if (type.includes('tab')) roles.push('tab');
|
|
2689
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');
|
|
2690
3022
|
return roles;
|
|
2691
3023
|
}
|
|
2692
3024
|
|
|
@@ -2746,18 +3078,25 @@ function renderDetails(node) {
|
|
|
2746
3078
|
|
|
2747
3079
|
// ── Mirror click ───────────────────────────────────────────────────────────
|
|
2748
3080
|
function resolveMirrorSourceSize() {
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
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.
|
|
2755
3088
|
const naturalWidth = Number(mirrorImg.naturalWidth || 0);
|
|
2756
3089
|
const naturalHeight = Number(mirrorImg.naturalHeight || 0);
|
|
2757
3090
|
if (naturalWidth > 1 && naturalHeight > 1) {
|
|
2758
3091
|
return { width: naturalWidth, height: naturalHeight };
|
|
2759
3092
|
}
|
|
2760
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
|
+
|
|
2761
3100
|
return { width: 1, height: 1 };
|
|
2762
3101
|
}
|
|
2763
3102
|
|
|
@@ -3452,6 +3791,27 @@ revokePermissionBtn.addEventListener('click', () => {
|
|
|
3452
3791
|
send({ type: 'app_action', action: 'revokePermission', identifier, permission });
|
|
3453
3792
|
});
|
|
3454
3793
|
|
|
3794
|
+
terminateSessionBtn.addEventListener('click', () => {
|
|
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;
|
|
3796
|
+
closeDeviceMenu();
|
|
3797
|
+
terminateSessionBtn.disabled = true;
|
|
3798
|
+
releaseSessionBtn.disabled = true;
|
|
3799
|
+
send({ type: 'terminate_session' });
|
|
3800
|
+
});
|
|
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
|
+
|
|
3455
3815
|
document.addEventListener('click', () => {
|
|
3456
3816
|
closeDeviceMenu();
|
|
3457
3817
|
closeDeviceSwitcher();
|