@astur-mobile/cli 0.1.0-beta.4 → 0.4.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({
@@ -366,37 +435,63 @@ export function startInspectorServer(inspector, device, options) {
366
435
  }));
367
436
  return;
368
437
  }
369
- const step = {
370
- index: steps.length,
371
- action: 'tap',
372
- locator: normalizeRecordingLocator(suggestions[0].code),
373
- };
438
+ const recordedLocator = normalizeRecordingLocator(suggestions[0].code);
439
+ const step = recordedLocator
440
+ ? { index: steps.length, action: 'tap', locator: recordedLocator }
441
+ : { index: steps.length, action: 'tapPoint', locator: '', point: { x: event.x, y: event.y } };
374
442
  steps.push(step);
375
443
  broadcast({ type: 'step', ...step });
376
- await syncInspectorState();
377
444
  broadcast({ type: 'status', message: 'Action OK: Tap recorded' });
378
445
  }
379
446
  break;
380
447
  }
381
448
  case 'direct_action': {
449
+ // The fill/tap buttons act on the currently selected node. When that is a
450
+ // spliced WebView DOM node, drive it by its DOM locator (in-page JS)
451
+ // instead of the native driver.
452
+ const selectedNode = selectedUid
453
+ ? currentNodes.find((node) => node.uid === selectedUid)
454
+ : undefined;
455
+ const selectedWebNode = selectedNode
456
+ ? webActionTargetForSelectedNode(selectedNode, currentNodes, event.action)
457
+ : undefined;
382
458
  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
- });
459
+ const runNative = () => activeInspector.executeAction(event.action === 'fill'
460
+ ? { kind: 'fill', selector: event.selector, value: event.value ?? '', options: { timeout: 2_000 } }
461
+ : { kind: 'tap', selector: event.selector, options: { timeout: 2_000 } });
462
+ let usedWeb = false;
463
+ await runDeviceAction(async () => {
464
+ if (selectedWebNode?.web && activeInspector.webAct) {
465
+ try {
466
+ await activeInspector.webAct(selectedWebNode.web, event.action === 'fill' ? 'fill' : 'tap', event.value);
467
+ usedWeb = true;
468
+ }
469
+ catch {
470
+ // The WebView DOM context may have momentarily dropped — fall back to
471
+ // the native driver rather than failing the action outright.
472
+ await runNative();
473
+ }
474
+ }
475
+ else {
476
+ await runNative();
477
+ }
478
+ });
479
+ // Record the Fill/Tap button action while recording so codegen captures it.
480
+ // Web actions record as a webContext call; native actions as a device locator.
481
+ if (recording) {
482
+ const value = event.action === 'fill' ? (event.value ?? '') : undefined;
483
+ const step = usedWeb && selectedWebNode?.web
484
+ ? { index: steps.length, action: event.action, locator: webContextLocatorCall(selectedWebNode.web), web: true, value }
485
+ : {
486
+ index: steps.length,
487
+ action: event.action,
488
+ locator: normalizeRecordingLocator(selectedNode ? (suggestLocatorsForNode(selectedNode, currentNodes)[0]?.code ?? '') : ''),
489
+ value
490
+ };
491
+ steps.push(step);
492
+ broadcast({ type: 'step', ...step });
397
493
  }
398
- await syncInspectorState();
399
- broadcast({ type: 'status', message: `Action OK: ${event.action === 'fill' ? 'Filled' : 'Tapped'}` });
494
+ broadcast({ type: 'status', message: `Action OK: ${event.action === 'fill' ? 'Filled' : 'Tapped'}${usedWeb ? ' (web)' : ''}` });
400
495
  }
401
496
  catch (error) {
402
497
  ws.send(JSON.stringify({
@@ -491,6 +586,29 @@ export function startInspectorServer(inspector, device, options) {
491
586
  setTimeout(() => process.exit(0), 300);
492
587
  break;
493
588
  }
589
+ case 'release_session': {
590
+ if (!options.releaseSession) {
591
+ ws.send(JSON.stringify({ type: 'status', message: 'Action Error: Release is unavailable in this session.' }));
592
+ break;
593
+ }
594
+ broadcast({ type: 'status', message: 'Action Pending: Releasing session...' });
595
+ try {
596
+ await options.releaseSession();
597
+ }
598
+ catch (error) {
599
+ broadcast({
600
+ type: 'status',
601
+ message: `Action Error: Release failed: ${formatActionError(error)}`
602
+ });
603
+ break;
604
+ }
605
+ broadcast({
606
+ type: 'terminated',
607
+ message: 'Session released. The device session was closed; the emulator/simulator was left running. You can close this tab.'
608
+ });
609
+ setTimeout(() => process.exit(0), 300);
610
+ break;
611
+ }
494
612
  case 'app_action': {
495
613
  if (!options.performAppAction) {
496
614
  ws.send(JSON.stringify({ type: 'status', message: 'Action Error: App actions are unavailable in this session.' }));
@@ -567,6 +685,49 @@ export function startInspectorServer(inspector, device, options) {
567
685
  }
568
686
  break;
569
687
  }
688
+ case 'drag': {
689
+ if (!options.performDrag) {
690
+ ws.send(JSON.stringify({ type: 'status', message: 'Action Error: Drag is unavailable in this session.' }));
691
+ break;
692
+ }
693
+ const now = Date.now();
694
+ if (gestureCommandInFlight || now - lastGestureCommandAt < 350) {
695
+ ws.send(JSON.stringify({ type: 'gesture_ack' }));
696
+ break;
697
+ }
698
+ gestureCommandInFlight = true;
699
+ lastGestureCommandAt = now;
700
+ try {
701
+ await runDeviceAction(() => options.performDrag(event.gesture));
702
+ if (recording && event.record !== false) {
703
+ // Record element-sourced when the drag start resolves to a stable
704
+ // locator (getByTestId(...).dragTo({x,y})); else fall back to a raw
705
+ // coordinate drag.
706
+ const startNode = findUiNodeAtPoint(currentNodes, event.gesture.start, { preferActionable: true });
707
+ const srcSuggestions = startNode ? suggestLocatorsForNode(startNode, currentNodes) : [];
708
+ const srcLocator = srcSuggestions[0] ? normalizeRecordingLocator(srcSuggestions[0].code) : '';
709
+ const step = {
710
+ index: steps.length,
711
+ action: 'drag',
712
+ locator: srcLocator,
713
+ gesture: event.gesture
714
+ };
715
+ steps.push(step);
716
+ broadcast({ type: 'step', ...step });
717
+ }
718
+ broadcast({ type: 'status', message: recording ? 'Action OK: Drag recorded' : 'Action OK: Dragged' });
719
+ }
720
+ catch (error) {
721
+ ws.send(JSON.stringify({
722
+ type: 'status',
723
+ message: `Action Error: Drag failed: ${formatActionError(error)}`
724
+ }));
725
+ }
726
+ finally {
727
+ gestureCommandInFlight = false;
728
+ }
729
+ break;
730
+ }
570
731
  case 'record_toggle': {
571
732
  recording = !recording;
572
733
  broadcast({ type: 'status', message: recording ? 'Recording ON' : 'Recording OFF' });
@@ -689,6 +850,11 @@ export function startInspectorServer(inspector, device, options) {
689
850
  let frameTimer;
690
851
  let frameInFlight = false;
691
852
  async function pushFrame() {
853
+ // Keep the mirror live even during a device action. A screenshot is read-only:
854
+ // it can't disturb the fill, re-focus the field, or drop the WebView context.
855
+ // Pausing it (only the heavy tree/WebView reads are paused — see pushTree) made
856
+ // a fill look like nothing happened in the mirror while the simulator already
857
+ // showed the typed text and keyboard.
692
858
  if (clients.size === 0 || frameInFlight) {
693
859
  return 'busy';
694
860
  }
@@ -740,7 +906,9 @@ export function startInspectorServer(inspector, device, options) {
740
906
  let lastTreeErrorAt = 0;
741
907
  let consecutiveTreeErrors = 0;
742
908
  async function pushTree(options = {}) {
743
- if (treeInFlight) {
909
+ // Bootstrap/manual refreshes (reportFirstError) bypass the action gate;
910
+ // background polls pause while a device action is running or settling.
911
+ if (treeInFlight || (devicePollingPaused() && !options.reportFirstError)) {
744
912
  return 'busy';
745
913
  }
746
914
  // Avoid expensive UI-tree reads (e.g. adb `uiautomator dump`, broad XCUI
@@ -753,6 +921,17 @@ export function startInspectorServer(inspector, device, options) {
753
921
  try {
754
922
  for await (const update of activeInspector.subscribeTree({ maxUpdates: 1 })) {
755
923
  const nodes = flattenSnapshot(update.root);
924
+ // Splice any WebView DOM (cached from the adaptive probe) under its native
925
+ // host so its elements appear in the tree with real DOM locators.
926
+ maybeProbeWebTree();
927
+ if (cachedWebTree) {
928
+ try {
929
+ nodes.push(...buildWebNodes(cachedWebTree, nodes));
930
+ }
931
+ catch {
932
+ // Best-effort: never let WebView DOM block the native tree.
933
+ }
934
+ }
756
935
  const viewport = estimateViewport(nodes);
757
936
  currentNodes = nodes;
758
937
  currentViewport = viewport;
@@ -813,7 +992,12 @@ export function startInspectorServer(inspector, device, options) {
813
992
  return { frame, tree: 'busy' };
814
993
  }
815
994
  function scheduleTree() {
816
- const interval = clients.size === 0
995
+ // Back off hard when the tree read keeps failing/timing out — e.g. XCUITest
996
+ // stalling on a heavy WebView snapshot. Retrying every interval just piles
997
+ // commands onto an already-stuck agent; poll slowly until it recovers (a
998
+ // successful read resets consecutiveTreeErrors and snaps back to base).
999
+ const stalled = consecutiveTreeErrors >= 3;
1000
+ const interval = clients.size === 0 || stalled
817
1001
  ? maxIdleIntervalMs
818
1002
  : nextPollInterval(baseTreeIntervalMs, treeIdleStreak, maxIdleIntervalMs);
819
1003
  treeTimer = setTimeout(async () => {
@@ -929,6 +1113,110 @@ function flattenNode(node, depth, uid, parentUid) {
929
1113
  bounds: node.bounds,
930
1114
  };
931
1115
  }
1116
+ function webLocatorCode(descriptor) {
1117
+ switch (descriptor.strategy) {
1118
+ case 'testid': return `device.getByTestId(${JSON.stringify(descriptor.value)})`;
1119
+ case 'id': return `device.getById(${JSON.stringify(descriptor.value)})`;
1120
+ case 'role': return `device.getByRole(${JSON.stringify(descriptor.value)}${descriptor.name ? `, { name: ${JSON.stringify(descriptor.name)} }` : ''})`;
1121
+ case 'text': return `device.getByText(${JSON.stringify(descriptor.value)})`;
1122
+ default: return `device.locator(${JSON.stringify(descriptor.value)})`;
1123
+ }
1124
+ }
1125
+ // Call form for a WebView DOM locator under a `web` context handle (no `device.`
1126
+ // prefix), e.g. `getByTestId('astur-password')` — used in recorded codegen as
1127
+ // `web.<call>.fill(...)` after `const web = await device.webContext();`.
1128
+ function webContextLocatorCall(descriptor) {
1129
+ switch (descriptor.strategy) {
1130
+ case 'testid': return `getByTestId(${JSON.stringify(descriptor.value)})`;
1131
+ case 'role': return `getByRole(${JSON.stringify(descriptor.value)}${descriptor.name ? `, { name: ${JSON.stringify(descriptor.name)} }` : ''})`;
1132
+ case 'text': return `getByText(${JSON.stringify(descriptor.value)})`;
1133
+ case 'id':
1134
+ default: return `getById(${JSON.stringify(descriptor.value)})`;
1135
+ }
1136
+ }
1137
+ function webLocatorSuggestion(descriptor) {
1138
+ const code = webLocatorCode(descriptor);
1139
+ // Web actions are routed by the DOM locator (node.web), not this selector, so it
1140
+ // only needs to be a valid placeholder for display/typing.
1141
+ const selector = descriptor.strategy === 'role' ? { strategy: 'role', value: descriptor.value, name: descriptor.name }
1142
+ : descriptor.strategy === 'text' ? { strategy: 'text', value: descriptor.value }
1143
+ : { strategy: 'id', value: descriptor.value };
1144
+ return { code, selector, score: 0.96, uniqueness: 1, stability: 0.9, readable: code.length <= 88, crossPlatform: true };
1145
+ }
1146
+ function webNodeTitle(node) {
1147
+ const tag = node.tag.toLowerCase();
1148
+ const handle = node.testId ? `[testid=${node.testId}]` : node.id ? `#${node.id}` : '';
1149
+ const name = (node.name ?? '').replace(/\s+/g, ' ').trim().slice(0, 40);
1150
+ return `<${tag}>${handle ? ` ${handle}` : ''}${name ? ` ${name}` : ''}`.trim();
1151
+ }
1152
+ /**
1153
+ * Locate the native node that hosts the WebView surface: the one whose device-px
1154
+ * bounds best match the web viewport scaled by the device pixel ratio. Used to
1155
+ * map DOM (CSS px) coordinates onto the device screen for overlays/hit-testing.
1156
+ */
1157
+ function findWebHostNode(web, nativeNodes) {
1158
+ const dpr = web.devicePixelRatio || 1;
1159
+ const targetW = web.viewport.width * dpr;
1160
+ const targetH = web.viewport.height * dpr;
1161
+ if (targetW < 8 || targetH < 8) {
1162
+ return undefined;
1163
+ }
1164
+ let best;
1165
+ let bestScore = Number.POSITIVE_INFINITY;
1166
+ for (const node of nativeNodes) {
1167
+ if (!node.visible || node.bounds.width < 8 || node.bounds.height < 8) {
1168
+ continue;
1169
+ }
1170
+ const dw = Math.abs(node.bounds.width - targetW);
1171
+ const dh = Math.abs(node.bounds.height - targetH);
1172
+ if (dw <= targetW * 0.25 && dh <= targetH * 0.25 && dw + dh < bestScore) {
1173
+ best = node;
1174
+ bestScore = dw + dh;
1175
+ }
1176
+ }
1177
+ return best;
1178
+ }
1179
+ /**
1180
+ * Flatten a WebView DOM snapshot into UiNodes spliced under its native host. DOM
1181
+ * actions run by locator (engine JS), so even when the host can't be matched the
1182
+ * nodes stay browsable/fillable — only the screenshot overlay needs the bounds.
1183
+ */
1184
+ function buildWebNodes(web, nativeNodes) {
1185
+ if (!web.root) {
1186
+ return [];
1187
+ }
1188
+ const host = findWebHostNode(web, nativeNodes);
1189
+ const dpr = web.devicePixelRatio || 1;
1190
+ const mapBounds = (b) => host
1191
+ ? {
1192
+ x: Math.round(host.bounds.x + b.x * dpr),
1193
+ y: Math.round(host.bounds.y + b.y * dpr),
1194
+ width: Math.round(b.width * dpr),
1195
+ height: Math.round(b.height * dpr)
1196
+ }
1197
+ : { x: 0, y: 0, width: 0, height: 0 };
1198
+ const out = [];
1199
+ const visit = (node, depth, uid, parentUid) => {
1200
+ out.push({
1201
+ uid,
1202
+ parentUid,
1203
+ depth,
1204
+ title: webNodeTitle(node),
1205
+ type: `web:${node.tag.toLowerCase()}`,
1206
+ id: node.id,
1207
+ label: node.name,
1208
+ text: node.role ? undefined : node.name,
1209
+ value: node.value,
1210
+ visible: node.visible,
1211
+ enabled: node.enabled,
1212
+ bounds: mapBounds(node.bounds),
1213
+ web: node.locator
1214
+ });
1215
+ node.children.forEach((child, index) => visit(child, depth + 1, `${uid}/${index}`, uid));
1216
+ };
1217
+ visit(web.root, host ? host.depth + 1 : 1, 'web', host?.uid);
1218
+ return out;
1219
+ }
932
1220
  function estimateViewport(nodes) {
933
1221
  const root = nodes[0];
934
1222
  if (root?.bounds && root.bounds.width > 0 && root.bounds.height > 0) {
@@ -995,6 +1283,10 @@ const INSPECTOR_ROLES = [
995
1283
  'textbox'
996
1284
  ];
997
1285
  function suggestLocatorsForNode(node, nodes) {
1286
+ if (node.web) {
1287
+ // DOM nodes carry the in-page best locator; the native ranking doesn't apply.
1288
+ return [webLocatorSuggestion(node.web)];
1289
+ }
998
1290
  const target = resolveInspectableNode(node, nodes);
999
1291
  const candidates = buildLocalLocatorCandidates(target);
1000
1292
  const suggestions = [];
@@ -1093,6 +1385,11 @@ function buildLocalLocatorCandidates(node) {
1093
1385
  return candidates;
1094
1386
  }
1095
1387
  function resolveInspectableNode(node, nodes, options = {}) {
1388
+ // WebView DOM nodes are already their own best target — never re-resolve them to
1389
+ // a native ancestor, or selection/action routing would lose the DOM locator.
1390
+ if (node.web) {
1391
+ return node;
1392
+ }
1096
1393
  const targetIsUsable = hasUsableLocator(node) && !isDecorativeNode(node);
1097
1394
  const targetIsActionable = targetIsUsable && isActionableNode(node);
1098
1395
  if (!options.preferActionable && targetIsUsable) {
@@ -1134,6 +1431,13 @@ function scoreInspectableNode(node, options = {}) {
1134
1431
  return Number.NEGATIVE_INFINITY;
1135
1432
  }
1136
1433
  let score = 0;
1434
+ // Prefer the WebView DOM node over an overlapping native accessibility node at the
1435
+ // same point. Inside a WebView both exist; the DOM node fills/taps reliably (webAct),
1436
+ // while the native overlay — especially secure text fields — often fails. This only
1437
+ // affects spliced web nodes (WebView regions); native screens are unchanged, and the
1438
+ // tree still lets you pick the native node explicitly.
1439
+ if (node.web)
1440
+ score += 100;
1137
1441
  if (normalizeLocatorToken(node.id))
1138
1442
  score += 80;
1139
1443
  if (normalizeLocatorToken(node.label))
@@ -1160,6 +1464,51 @@ function scoreInspectableNode(node, options = {}) {
1160
1464
  }
1161
1465
  return score + Math.min(node.depth, 14);
1162
1466
  }
1467
+ function webActionTargetForSelectedNode(selected, nodes, action) {
1468
+ if (selected.web && webNodeSupportsAction(selected, action)) {
1469
+ return selected;
1470
+ }
1471
+ const selectedArea = selected.bounds.width * selected.bounds.height;
1472
+ if (selectedArea <= 0) {
1473
+ return undefined;
1474
+ }
1475
+ const selectedCenter = centerOfBounds(selected.bounds);
1476
+ let best;
1477
+ let bestScore = Number.NEGATIVE_INFINITY;
1478
+ for (const node of nodes) {
1479
+ if (!node.web || !node.visible || !node.enabled || !webNodeSupportsAction(node, action)) {
1480
+ continue;
1481
+ }
1482
+ const overlap = intersectionArea(selected.bounds, node.bounds);
1483
+ const nodeArea = node.bounds.width * node.bounds.height;
1484
+ if (nodeArea <= 0 || overlap <= 0) {
1485
+ continue;
1486
+ }
1487
+ const overlapRatio = overlap / Math.min(selectedArea, nodeArea);
1488
+ const centerHit = containsPoint(node.bounds, selectedCenter);
1489
+ if (!centerHit && overlapRatio < 0.55) {
1490
+ continue;
1491
+ }
1492
+ const score = overlapRatio * 100
1493
+ + (centerHit ? 20 : 0)
1494
+ + Math.min(node.depth, 20)
1495
+ - Math.log10(nodeArea) * 2;
1496
+ if (!best || score > bestScore) {
1497
+ best = node;
1498
+ bestScore = score;
1499
+ }
1500
+ }
1501
+ return best;
1502
+ }
1503
+ function webNodeSupportsAction(node, action) {
1504
+ if (!node.web) {
1505
+ return false;
1506
+ }
1507
+ if (action === 'fill') {
1508
+ return isFillableNode(node);
1509
+ }
1510
+ return isActionableNode(node);
1511
+ }
1163
1512
  function uiNodeMatchesSelector(node, selector) {
1164
1513
  switch (selector.strategy) {
1165
1514
  case 'accessibility':
@@ -1232,6 +1581,8 @@ function rolesForNode(node) {
1232
1581
  roles.add('tab');
1233
1582
  if (type.includes('edittext') || type.includes('textfield') || type.includes('securetextfield') || type.includes('searchfield') || type.includes('textinput'))
1234
1583
  roles.add('textbox');
1584
+ if (type === 'web:input' || type === 'web:textarea' || type.includes('contenteditable'))
1585
+ roles.add('textbox');
1235
1586
  if (type.includes('textview') || type.includes('statictext') || type.includes('label'))
1236
1587
  roles.add('text');
1237
1588
  return [...roles];
@@ -1355,6 +1706,19 @@ function containsPoint(bounds, point) {
1355
1706
  && point.x <= bounds.x + bounds.width
1356
1707
  && point.y <= bounds.y + bounds.height;
1357
1708
  }
1709
+ function centerOfBounds(bounds) {
1710
+ return {
1711
+ x: Math.round(bounds.x + bounds.width / 2),
1712
+ y: Math.round(bounds.y + bounds.height / 2)
1713
+ };
1714
+ }
1715
+ function intersectionArea(a, b) {
1716
+ const left = Math.max(a.x, b.x);
1717
+ const top = Math.max(a.y, b.y);
1718
+ const right = Math.min(a.x + a.width, b.x + b.width);
1719
+ const bottom = Math.min(a.y + a.height, b.y + b.height);
1720
+ return Math.max(0, right - left) * Math.max(0, bottom - top);
1721
+ }
1358
1722
  async function readAsturLogoDataUri() {
1359
1723
  const candidates = [
1360
1724
  fileURLToPath(new URL('../assets/brand/astur-logo-dark.png', import.meta.url)),
@@ -1391,14 +1755,28 @@ function generateTestCode(steps, lang) {
1391
1755
  const importLine = lang === 'typescript'
1392
1756
  ? `import { test, expect } from '@astur-mobile/test';`
1393
1757
  : `const { test, expect } = require('@astur-mobile/test');`;
1394
- const lines = steps.map((s) => generateRecordedStepCode(s));
1758
+ // Web steps run through a WebView DOM context — emit the handle once.
1759
+ const setup = steps.some((s) => s.web) ? [' const web = await device.webContext();'] : [];
1760
+ const lines = setup.concat(steps.map((s) => generateRecordedStepCode(s)));
1395
1761
  return `${importLine}\n\ntest('recorded flow', async ({ device }) => {\n${lines.join('\n')}\n});\n`;
1396
1762
  }
1397
1763
  function generateRecordedStepCode(step) {
1398
1764
  const locator = normalizeRecordingLocator(step.locator);
1765
+ if (step.web && locator) {
1766
+ // WebView DOM action via the `web` context handle.
1767
+ return step.action === 'fill'
1768
+ ? ` await web.${locator}.fill(${JSON.stringify(step.value ?? '')});`
1769
+ : ` await web.${locator}.tap();`;
1770
+ }
1399
1771
  if (step.action === 'swipe' && step.gesture) {
1400
1772
  return ` await device.swipe(${JSON.stringify(step.gesture)});`;
1401
1773
  }
1774
+ if (step.action === 'drag' && step.gesture) {
1775
+ if (locator) {
1776
+ return ` await device.${locator}.dragTo({ x: ${step.gesture.end.x}, y: ${step.gesture.end.y} });`;
1777
+ }
1778
+ return ` await device.drag(${JSON.stringify(step.gesture)});`;
1779
+ }
1402
1780
  if (step.action === 'tapPoint' && step.point) {
1403
1781
  return ` await device.tap(${JSON.stringify(step.point)});`;
1404
1782
  }
@@ -1423,6 +1801,13 @@ function generateRecordedStepCode(step) {
1423
1801
  return ` await expect(${actual}).toBeVisible();`;
1424
1802
  }
1425
1803
  }
1804
+ if (!locator) {
1805
+ // Never emit broken `device..tap()` — fall back to a coordinate tap when we
1806
+ // have a point, otherwise leave a clear marker to re-record.
1807
+ return step.point
1808
+ ? ` await device.tap(${JSON.stringify(step.point)});`
1809
+ : ` // TODO: tap target had no stable locator — re-record this step in Inspect mode`;
1810
+ }
1426
1811
  return ` await device.${locator}.tap();`;
1427
1812
  }
1428
1813
  const BASE_INSPECTOR_DEVICE_ACTIONS = [
@@ -1655,6 +2040,7 @@ html,body{height:100%;overflow:hidden;background:var(--bg);color:var(--text);fon
1655
2040
  #phone-notch{display:none}
1656
2041
  #mirror-stage{position:relative;cursor:crosshair;overflow:hidden;border-radius:28px;background:#0a0a0f;box-shadow:0 24px 64px rgba(0,0,0,.48),0 0 0 1px rgba(255,255,255,.08);width:360px;height:720px;max-width:calc(100vw - 420px);max-height:calc(100vh - 96px)}
1657
2042
  #mirror-stage[data-mode="interact"]{cursor:pointer}
2043
+ #mirror-stage[data-mode="drag"]{cursor:grab}
1658
2044
  #mirror-stage.dragging{cursor:grabbing}
1659
2045
  #mirror-img{display:block;max-width:100%;user-select:none;pointer-events:none;border-radius:inherit}
1660
2046
  #mirror-img.placeholder{opacity:.15}
@@ -1770,10 +2156,11 @@ html,body{height:100%;overflow:hidden;background:var(--bg);color:var(--text);fon
1770
2156
  ${renderInspectorDeviceActionMenu(device)}
1771
2157
  <div class="device-menu-section">
1772
2158
  <div class="device-menu-label">Session</div>
1773
- <div class="device-row">
1774
- <button type="button" class="device-action-btn danger" id="terminate-session-btn">Terminate session</button>
2159
+ <div class="device-menu-actions">
2160
+ <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>
2161
+ <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
2162
  </div>
1776
- <div class="device-help">Closes the device session and powers off the emulator/simulator to free memory. Real devices stay on.</div>
2163
+ <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
2164
  </div>
1778
2165
  </div>
1779
2166
  </div>
@@ -1792,6 +2179,7 @@ html,body{height:100%;overflow:hidden;background:var(--bg);color:var(--text);fon
1792
2179
  <div class="action-mode-row" role="group" aria-label="Mirror click mode">
1793
2180
  <button type="button" id="inspect-mode-btn" class="mode-btn active" title="Select elements in the mirror">Inspect</button>
1794
2181
  <button type="button" id="interact-mode-btn" class="mode-btn" title="Tap the device without recording">Interact</button>
2182
+ <button type="button" id="drag-mode-btn" class="mode-btn" title="Press and drag on the mirror to move an element (e.g. drag-and-drop)">Drag</button>
1795
2183
  </div>
1796
2184
  <div class="element-action-row">
1797
2185
  <button type="button" id="element-tap-btn" class="element-action-btn" disabled>Tap</button>
@@ -1842,7 +2230,11 @@ html,body{height:100%;overflow:hidden;background:var(--bg);color:var(--text);fon
1842
2230
  <div id="right-panel">
1843
2231
  <!-- Tree -->
1844
2232
  <div id="tree-panel">
1845
- <div class="panel-header">UI Tree <span id="tree-badge" style="font-weight:400;color:var(--text-muted)"></span></div>
2233
+ <div class="panel-header" style="display:flex;align-items:center;gap:8px">
2234
+ <span>UI Tree</span>
2235
+ <span id="tree-badge" style="font-weight:400;color:var(--text-muted)"></span>
2236
+ <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>
2237
+ </div>
1846
2238
  <div id="tree-search-row">
1847
2239
  <input id="tree-search" type="search" placeholder="Search element…"/>
1848
2240
  </div>
@@ -1919,6 +2311,20 @@ let activeTab = 'code';
1919
2311
  let currentDevice = ${JSON.stringify(toBootstrapDevice(device))};
1920
2312
  let devices = [currentDevice];
1921
2313
  let busyCount = 0;
2314
+ let busyWatchdog = null;
2315
+ // Hard ceiling on how long the "Running action…" overlay may stay up. If the
2316
+ // device/agent stops responding (e.g. XCUITest stalls while snapshotting a heavy
2317
+ // WebView), no status ever comes back and the overlay would otherwise spin
2318
+ // forever, bricking the session. The watchdog force-clears it so the user can
2319
+ // keep working (switch screens, terminate, etc.).
2320
+ //
2321
+ // Must sit ABOVE the native agent command timeout (iOS default 30s) — otherwise a
2322
+ // slow-but-succeeding action gets a premature, misleading "timed out" while the
2323
+ // agent is still completing it (e.g. filling a field inside a never-idle WKWebView,
2324
+ // where each keystroke's bounded idle-wait makes a clear+type land around ~16s). At
2325
+ // 35s the agent's own success/timeout response always arrives first, so the user
2326
+ // sees the real outcome; this stays purely a backstop for a truly hung session.
2327
+ const BUSY_WATCHDOG_MS = 35000;
1922
2328
  let socketConnected = false;
1923
2329
  let hasFrame = false;
1924
2330
  let hasTree = false;
@@ -1954,6 +2360,7 @@ const highlightOverlay = $('highlight-overlay');
1954
2360
  const inspectorHint = $('inspector-hint');
1955
2361
  const inspectModeBtn = $('inspect-mode-btn');
1956
2362
  const interactModeBtn = $('interact-mode-btn');
2363
+ const dragModeBtn = $('drag-mode-btn');
1957
2364
  const elementTapBtn = $('element-tap-btn');
1958
2365
  const elementFillInput = $('element-fill-input');
1959
2366
  const elementFillBtn = $('element-fill-btn');
@@ -1990,6 +2397,8 @@ const revokePermissionBtn = $('revoke-permission-btn');
1990
2397
  const clearDataBtn = $('clear-data-btn');
1991
2398
  const clearCacheBtn = $('clear-cache-btn');
1992
2399
  const terminateSessionBtn = $('terminate-session-btn');
2400
+ const releaseSessionBtn = $('release-session-btn');
2401
+ const treeRefreshBtn = $('tree-refresh-btn');
1993
2402
  const main = $('main');
1994
2403
  const leftColumnSplitter = $('left-column-splitter');
1995
2404
  const rightColumnSplitter = $('right-column-splitter');
@@ -2194,6 +2603,25 @@ function toggleDeviceSwitcher() {
2194
2603
 
2195
2604
  function setBusy(active) {
2196
2605
  busyCount = Math.max(0, busyCount + (active ? 1 : -1));
2606
+ if (busyWatchdog) {
2607
+ clearTimeout(busyWatchdog);
2608
+ busyWatchdog = null;
2609
+ }
2610
+ if (busyCount > 0) {
2611
+ busyWatchdog = setTimeout(onBusyWatchdogExpired, BUSY_WATCHDOG_MS);
2612
+ }
2613
+ updateDeviceReadiness();
2614
+ }
2615
+
2616
+ // Fires when an action's overlay has been up too long with no response. Clears
2617
+ // the overlay and tells the user the session is still live so they can recover
2618
+ // (common when a native+web hybrid screen leaves XCUITest mid-snapshot).
2619
+ function onBusyWatchdogExpired() {
2620
+ busyWatchdog = null;
2621
+ if (busyCount === 0) return;
2622
+ busyCount = 0;
2623
+ releaseGestureLock();
2624
+ 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
2625
  updateDeviceReadiness();
2198
2626
  }
2199
2627
 
@@ -2755,6 +3183,10 @@ function nodeRoles(node) {
2755
3183
  if (type.includes('switch')) roles.push('switch');
2756
3184
  if (type.includes('tab')) roles.push('tab');
2757
3185
  if (type.includes('edittext') || type.includes('textfield') || type.includes('securetextfield') || type.includes('searchfield') || type.includes('textinput')) roles.push('textbox');
3186
+ // Spliced WebView DOM nodes ('web:input', 'web:textarea'): text inputs are fillable
3187
+ // (and contenteditable surfaces). Without this the Fill control stays disabled for
3188
+ // web-context elements even though the server routes them through webAct.
3189
+ if (type === 'web:input' || type === 'web:textarea' || type.includes('contenteditable')) roles.push('textbox');
2758
3190
  return roles;
2759
3191
  }
2760
3192
 
@@ -2782,13 +3214,16 @@ function updateStepControls() {
2782
3214
 
2783
3215
  function setMirrorMode(mode) {
2784
3216
  mirrorMode = mode;
2785
- const interacting = mode === 'interact';
2786
- inspectModeBtn.classList.toggle('active', !interacting);
2787
- interactModeBtn.classList.toggle('active', interacting);
3217
+ inspectModeBtn.classList.toggle('active', mode === 'inspect');
3218
+ interactModeBtn.classList.toggle('active', mode === 'interact');
3219
+ dragModeBtn.classList.toggle('active', mode === 'drag');
2788
3220
  mirrorStage.dataset.mode = mode;
2789
- inspectorHint.textContent = interacting
2790
- ? 'Tap the screen to interact with the device without recording.'
2791
- : 'Tap on the screen or select an element in the tree to generate locators.';
3221
+ inspectorHint.textContent =
3222
+ mode === 'interact'
3223
+ ? 'Tap the screen to interact with the device without recording.'
3224
+ : mode === 'drag'
3225
+ ? 'Press and drag on the mirror to move an element. Turn on Record to capture it as a drag step.'
3226
+ : 'Tap on the screen or select an element in the tree to generate locators.';
2792
3227
  }
2793
3228
 
2794
3229
  // ── Details ────────────────────────────────────────────────────────────────
@@ -2814,18 +3249,25 @@ function renderDetails(node) {
2814
3249
 
2815
3250
  // ── Mirror click ───────────────────────────────────────────────────────────
2816
3251
  function resolveMirrorSourceSize() {
2817
- const viewportWidth = Number(viewport.width || 0);
2818
- const viewportHeight = Number(viewport.height || 0);
2819
- if (viewportWidth > 1 && viewportHeight > 1) {
2820
- return { width: viewportWidth, height: viewportHeight };
2821
- }
2822
-
3252
+ // Size the stage to the screenshot's own pixels first: those are exactly what
3253
+ // gets drawn, and the image is sized with explicit width/height (no
3254
+ // object-fit), so any aspect-ratio mismatch stretches it. On rotation the
3255
+ // landscape frame arrives a tick before the tree's viewport updates — if we
3256
+ // trusted the (still portrait) viewport here, the landscape image would be
3257
+ // squeezed into a portrait stage. Fall back to the viewport, then a square,
3258
+ // until the first frame has loaded.
2823
3259
  const naturalWidth = Number(mirrorImg.naturalWidth || 0);
2824
3260
  const naturalHeight = Number(mirrorImg.naturalHeight || 0);
2825
3261
  if (naturalWidth > 1 && naturalHeight > 1) {
2826
3262
  return { width: naturalWidth, height: naturalHeight };
2827
3263
  }
2828
3264
 
3265
+ const viewportWidth = Number(viewport.width || 0);
3266
+ const viewportHeight = Number(viewport.height || 0);
3267
+ if (viewportWidth > 1 && viewportHeight > 1) {
3268
+ return { width: viewportWidth, height: viewportHeight };
3269
+ }
3270
+
2829
3271
  return { width: 1, height: 1 };
2830
3272
  }
2831
3273
 
@@ -2861,8 +3303,11 @@ mirrorStage.addEventListener('click', e => {
2861
3303
 
2862
3304
  const point = mirrorEventPoint(e);
2863
3305
  const performTap = mirrorMode === 'interact' && !recording;
2864
- if (recording || performTap) setBusy(true);
2865
- send({ type: 'click', x: point.x, y: point.y, record: recording, perform: performTap });
3306
+ // In Drag mode a click only selects/inspects — it must never record a tap, so a
3307
+ // small-movement drag can't leak a locator-less tap into the recording.
3308
+ const recordTap = recording && mirrorMode !== 'drag';
3309
+ if (recordTap || performTap) setBusy(true);
3310
+ send({ type: 'click', x: point.x, y: point.y, record: recordTap, perform: performTap });
2866
3311
  });
2867
3312
 
2868
3313
  mirrorStage.addEventListener('wheel', e => {
@@ -2894,11 +3339,13 @@ mirrorStage.addEventListener('pointerup', e => {
2894
3339
 
2895
3340
  suppressNextClick = true;
2896
3341
  const end = mirrorEventPoint(e);
2897
- sendGesture({
2898
- start: start.point,
2899
- end,
2900
- durationMs: 350
2901
- });
3342
+ if (mirrorMode === 'drag') {
3343
+ // Press-and-hold then drag — routes to device.drag (no swipe/scroll shortcut),
3344
+ // so draggable tiles actually pick up instead of scrolling the screen.
3345
+ sendDrag({ start: start.point, end, durationMs: 700 });
3346
+ } else {
3347
+ sendGesture({ start: start.point, end, durationMs: 350 });
3348
+ }
2902
3349
  });
2903
3350
 
2904
3351
  mirrorStage.addEventListener('pointercancel', e => {
@@ -2919,7 +3366,7 @@ function mirrorEventPoint(e) {
2919
3366
  };
2920
3367
  }
2921
3368
 
2922
- function sendGesture(gesture) {
3369
+ function sendMirrorGesture(type, gesture) {
2923
3370
  const now = Date.now();
2924
3371
  if (gestureInFlight || now < nextGestureAllowedAt) {
2925
3372
  return false;
@@ -2929,7 +3376,7 @@ function sendGesture(gesture) {
2929
3376
  nextGestureAllowedAt = now + 450;
2930
3377
  setBusy(true);
2931
3378
  send({
2932
- type: 'swipe',
3379
+ type,
2933
3380
  record: recording,
2934
3381
  gesture
2935
3382
  });
@@ -2946,6 +3393,14 @@ function sendGesture(gesture) {
2946
3393
  return true;
2947
3394
  }
2948
3395
 
3396
+ function sendGesture(gesture) {
3397
+ return sendMirrorGesture('swipe', gesture);
3398
+ }
3399
+
3400
+ function sendDrag(gesture) {
3401
+ return sendMirrorGesture('drag', gesture);
3402
+ }
3403
+
2949
3404
  function wheelSwipeGesture(e) {
2950
3405
  const horizontal = Math.abs(e.deltaX) > Math.abs(e.deltaY);
2951
3406
  const width = viewport.width || 1;
@@ -3161,7 +3616,16 @@ function updateCodeBlock() {
3161
3616
  if (!steps.length) { codeBlock.textContent = '// No steps recorded yet'; return; }
3162
3617
  const lines = steps.map(s => {
3163
3618
  const locator = normalizeLocatorCode(s.locator);
3619
+ if (s.web && locator) {
3620
+ return s.action === 'fill'
3621
+ ? ' await web.' + locator + '.fill(' + JSON.stringify(s.value || '') + ');'
3622
+ : ' await web.' + locator + '.tap();';
3623
+ }
3164
3624
  if (s.action === 'swipe' && s.gesture) return ' await device.swipe(' + JSON.stringify(s.gesture) + ');';
3625
+ if (s.action === 'drag' && s.gesture) {
3626
+ if (locator) return ' await device.' + locator + '.dragTo({ x: ' + s.gesture.end.x + ', y: ' + s.gesture.end.y + ' });';
3627
+ return ' await device.drag(' + JSON.stringify(s.gesture) + ');';
3628
+ }
3165
3629
  if (s.action === 'tapPoint' && s.point) return ' await device.tap(' + JSON.stringify(s.point) + ');';
3166
3630
  if (s.action === 'fill') return ' await device.' + locator + '.fill(' + JSON.stringify(s.value || '') + ');';
3167
3631
  if (s.action === 'expect') {
@@ -3181,12 +3645,18 @@ function updateCodeBlock() {
3181
3645
  return ' await expect(' + actual + ').toBeVisible();';
3182
3646
  }
3183
3647
  }
3648
+ if (!locator) {
3649
+ return s.point ? ' await device.tap(' + JSON.stringify(s.point) + ');' : ' // TODO: tap target had no stable locator';
3650
+ }
3184
3651
  return ' await device.' + locator + '.tap();';
3185
3652
  });
3186
3653
  const imp = codeLang === 'typescript'
3187
3654
  ? "import { test, expect } from '@astur-mobile/test';"
3188
3655
  : "const { test, expect } = require('@astur-mobile/test');";
3189
- const body = imp + "\\n\\ntest('recorded flow', async ({ device }) => {\\n" + lines.join('\\n') + "\\n});\\n";
3656
+ const allLines = steps.some(s => s.web)
3657
+ ? [' const web = await device.webContext();'].concat(lines)
3658
+ : lines;
3659
+ const body = imp + "\\n\\ntest('recorded flow', async ({ device }) => {\\n" + allLines.join('\\n') + "\\n});\\n";
3190
3660
  codeBlock.textContent = body;
3191
3661
  }
3192
3662
 
@@ -3336,6 +3806,7 @@ function runSelectedElementFill() {
3336
3806
  });
3337
3807
  }
3338
3808
 
3809
+ dragModeBtn.addEventListener('click', () => setMirrorMode('drag'));
3339
3810
  inspectModeBtn.addEventListener('click', () => setMirrorMode('inspect'));
3340
3811
  interactModeBtn.addEventListener('click', () => setMirrorMode('interact'));
3341
3812
  elementTapBtn.addEventListener('click', runSelectedElementTap);
@@ -3524,10 +3995,23 @@ terminateSessionBtn.addEventListener('click', () => {
3524
3995
  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
3996
  closeDeviceMenu();
3526
3997
  terminateSessionBtn.disabled = true;
3527
- terminateSessionBtn.textContent = 'Terminating…';
3998
+ releaseSessionBtn.disabled = true;
3528
3999
  send({ type: 'terminate_session' });
3529
4000
  });
3530
4001
 
4002
+ releaseSessionBtn.addEventListener('click', () => {
4003
+ 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;
4004
+ closeDeviceMenu();
4005
+ releaseSessionBtn.disabled = true;
4006
+ terminateSessionBtn.disabled = true;
4007
+ send({ type: 'release_session' });
4008
+ });
4009
+
4010
+ treeRefreshBtn.addEventListener('click', () => {
4011
+ showDeviceStatus('Refreshing UI tree...', 'pending');
4012
+ send({ type: 'device_action', action: 'tree.refresh' });
4013
+ });
4014
+
3531
4015
  document.addEventListener('click', () => {
3532
4016
  closeDeviceMenu();
3533
4017
  closeDeviceSwitcher();