@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.
@@ -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
- if (options.performTap) {
352
- await options.performTap({ x: event.x, y: event.y });
353
- }
354
- else {
355
- await activeInspector.executeAction({
356
- kind: 'tap',
357
- selector: suggestions[0].selector,
358
- options: { timeout: 2_000 }
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
- if (event.action === 'fill') {
384
- await activeInspector.executeAction({
385
- kind: 'fill',
386
- selector: event.selector,
387
- value: event.value ?? '',
388
- options: { timeout: 2_000 }
389
- });
390
- }
391
- else {
392
- await activeInspector.executeAction({
393
- kind: 'tap',
394
- selector: event.selector,
395
- options: { timeout: 2_000 }
396
- });
397
- }
398
- await syncInspectorState();
399
- broadcast({ type: 'status', message: `Action OK: ${event.action === 'fill' ? 'Filled' : 'Tapped'}` });
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
- broadcast({ type: 'status', message: `Action OK: Switched to ${activeDevice.name}` });
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
- if (treeInFlight) {
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
- const interval = clients.size === 0
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">UI Tree <span id="tree-badge" style="font-weight:400;color:var(--text-muted)"></span></div>
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
- const viewportWidth = Number(viewport.width || 0);
2750
- const viewportHeight = Number(viewport.height || 0);
2751
- if (viewportWidth > 1 && viewportHeight > 1) {
2752
- return { width: viewportWidth, height: viewportHeight };
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();