@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.
- package/README.md +16 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -1
- package/dist/inspectorServer.d.ts +23 -1
- package/dist/inspectorServer.d.ts.map +1 -1
- package/dist/inspectorServer.js +546 -62
- 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({
|
|
@@ -366,37 +435,63 @@ export function startInspectorServer(inspector, device, options) {
|
|
|
366
435
|
}));
|
|
367
436
|
return;
|
|
368
437
|
}
|
|
369
|
-
const
|
|
370
|
-
|
|
371
|
-
action: 'tap',
|
|
372
|
-
locator:
|
|
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
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
1774
|
-
<button type="button" class="device-action-btn
|
|
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">
|
|
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"
|
|
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
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
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 =
|
|
2790
|
-
|
|
2791
|
-
|
|
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
|
-
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
|
|
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
|
-
|
|
2865
|
-
|
|
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
|
-
|
|
2898
|
-
|
|
2899
|
-
|
|
2900
|
-
durationMs:
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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();
|