@deeeed/metamask-harness 0.86.0 → 0.86.1
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/CHANGELOG.md +8 -0
- package/dist/adapters/mobile/bridge-script.js +12 -0
- package/dist/adapters/mobile/feature-flags.js +2 -6
- package/dist/adapters/mobile/run.js +2 -2
- package/dist/adapters/mobile/ui.js +2 -1
- package/dist/commands/status-probe.js +2 -6
- package/library/actions/extension/perps/close_visible_position.mjs +17 -2
- package/library/actions/mobile/platform/bridge.mjs +2 -0
- package/library/actions/mobile/platform/observe-ui.mjs +79 -8
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.86.1 - 2026-10-09
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Extension `metamask.perps.close_visible_position cleanup_only=true` reconciles the final balance against the margin the close releases. With no placement receipt, the baseline is the available balance read while the position is still open. That baseline excludes the position's margin, so a clean full close went over the $1 tolerance and left an `outcome-unknown` receipt. A 0.11 SOL testnet close moved $524.42 to $536.45 and failed this way. The action now reads the close modal's "You'll receive" amount before dispatch and records it in the receipt as `closeReleaseBaseUnits`. It then checks the final balance against baseline + receive, with the same tolerance. If the amount is not visible, the action refuses before publishing a receipt or clicking. Closes backed by a placement receipt still reconcile against the pre-order balance. Cleanup output adds `releaseBaseUnits` and `expectedBalanceAfterBaseUnits`.
|
|
10
|
+
- Mobile `metamask.perps.read_visible_state` on iOS sees Perps Text test IDs. iOS accessibility lists React Native Text as a StaticText with no identifier, so a visible Lite market price (`perps-market-header-price`) failed `require_features: ["price"]`. On a non-opaque iOS run, `ui.visible` now reads each mounted `perps-` Text's test ID, string and size over the dev bridge. It gives that test ID to the on-screen StaticText with the same label and size, when exactly one test ID matches. Offscreen StaticText stays unbound, and so does a same-text twin of another size (the collapsed header price). Other Text, such as a revealed Secret Recovery Phrase word, is not read. This reaches every iOS `ui.visible` consumer: `ui.locators`, native-navigation test-ID waits, `readVisiblePerpsTestIds`, `configure_order`, and the assets and swap-bridge `read_visible_state`. Opaque runtimes and Android are unchanged. A non-opaque run with Metro down pays one bridge attempt (at most 2 s) per observation and gets a `ui.visible` warning; a read over 500 Texts is truncated with a warning.
|
|
11
|
+
- An empty `METAMASK_RECIPE_MOBILE_BRIDGE_SCRIPT` means the bundled `cdp-bridge.cjs` everywhere the TypeScript side spawns the Mobile bridge, as `run` and the action library already treated it. `launch`'s feature-flag reads and pins (and `doctor`'s) and the `status` live probe read the variable with `??`. An empty value made them check for a script named `""`, so they reported no runtime and never spawned a bridge. All TypeScript callers now share one `mobileBridgeScript()`; the action library keeps the same rule in its own `bridgeScript()` because it cannot import `src/`.
|
|
12
|
+
|
|
5
13
|
## 0.86.0 - 2026-10-09
|
|
6
14
|
|
|
7
15
|
### Added
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { runnerDir } from "../../paths.js";
|
|
3
|
+
function mobileBridgeScriptOverride() {
|
|
4
|
+
return process.env.METAMASK_RECIPE_MOBILE_BRIDGE_SCRIPT || void 0;
|
|
5
|
+
}
|
|
6
|
+
function mobileBridgeScript() {
|
|
7
|
+
return mobileBridgeScriptOverride() ?? path.join(runnerDir, "adapters", "mobile", "bridge-runtime", "cdp-bridge.cjs");
|
|
8
|
+
}
|
|
9
|
+
export {
|
|
10
|
+
mobileBridgeScript,
|
|
11
|
+
mobileBridgeScriptOverride
|
|
12
|
+
};
|
|
@@ -1,11 +1,7 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import fs from "node:fs";
|
|
3
|
-
import
|
|
4
|
-
import { runnerDir } from "../../paths.js";
|
|
3
|
+
import { mobileBridgeScript } from "./bridge-script.js";
|
|
5
4
|
const DEFAULT_TIMEOUT_MS = 1e4;
|
|
6
|
-
function bridgeScriptPath() {
|
|
7
|
-
return process.env.METAMASK_RECIPE_MOBILE_BRIDGE_SCRIPT ?? path.join(runnerDir, "adapters", "mobile", "bridge-runtime", "cdp-bridge.cjs");
|
|
8
|
-
}
|
|
9
5
|
const CONTROLLER_PREAMBLE = `
|
|
10
6
|
var ctx = globalThis.Engine && globalThis.Engine.context;
|
|
11
7
|
var c = ctx && ctx.RemoteFeatureFlagController;
|
|
@@ -19,7 +15,7 @@ function evalExpression(body) {
|
|
|
19
15
|
return `(function(){ try {${CONTROLLER_PREAMBLE}${body} } catch (e) { return JSON.stringify({ ok: false, error: e && (e.message || String(e)) }); } })()`;
|
|
20
16
|
}
|
|
21
17
|
function runBridgeEval(target, expression, timeoutMs) {
|
|
22
|
-
const script =
|
|
18
|
+
const script = mobileBridgeScript();
|
|
23
19
|
if (!fs.existsSync(script)) return Promise.resolve(null);
|
|
24
20
|
return new Promise((resolve) => {
|
|
25
21
|
let settled = false;
|
|
@@ -2,7 +2,6 @@ import { EXIT, isRecord } from "@farmslot/recipe-cli/harness";
|
|
|
2
2
|
import { spawnSync } from "node:child_process";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { IOS_SIMULATOR_RECORDING_TARGET_PREFIX } from "@farmslot/adapter-rn";
|
|
5
|
-
import { runnerDir } from "../../paths.js";
|
|
6
5
|
import { devicePlatformFromEnv } from "../performance/device-platform.js";
|
|
7
6
|
import {
|
|
8
7
|
resolveAndroidDeviceSerial,
|
|
@@ -10,6 +9,7 @@ import {
|
|
|
10
9
|
} from "./recording-target.js";
|
|
11
10
|
import { emitProofFailure } from "../proof-failure.js";
|
|
12
11
|
import { mobileAppIdentityEnv } from "./app-identity.js";
|
|
12
|
+
import { mobileBridgeScript } from "./bridge-script.js";
|
|
13
13
|
import { requireCurrentMobileReleaseArtifactState } from "./release-artifact-state.js";
|
|
14
14
|
const EXPLICIT_DEVICE_KEYS = [
|
|
15
15
|
"PLATFORM",
|
|
@@ -158,7 +158,7 @@ const mobileObservation = {
|
|
|
158
158
|
}
|
|
159
159
|
};
|
|
160
160
|
function runMobileIssueCommand(projectRoot, command) {
|
|
161
|
-
const bridge =
|
|
161
|
+
const bridge = mobileBridgeScript();
|
|
162
162
|
const result = spawnSync(process.execPath, [bridge, command], {
|
|
163
163
|
cwd: projectRoot,
|
|
164
164
|
env: { ...process.env, APP_ROOT: projectRoot, CDP_TIMEOUT: "5000" },
|
|
@@ -10,6 +10,7 @@ import { trustedMutationContextExtras } from "../../funding-execution-context.js
|
|
|
10
10
|
import { LIBRARY_ACTION_NAMESPACE } from "../../paths.js";
|
|
11
11
|
import { liveTsxCandidates } from "../../live-tsx.js";
|
|
12
12
|
import { mobileAppIdentityEnv } from "./app-identity.js";
|
|
13
|
+
import { mobileBridgeScriptOverride } from "./bridge-script.js";
|
|
13
14
|
const execFileAsync = promisify(execFile);
|
|
14
15
|
function sleep(ms) {
|
|
15
16
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
@@ -58,7 +59,7 @@ function mobileProbeOutput(status, input, projectRoot) {
|
|
|
58
59
|
};
|
|
59
60
|
}
|
|
60
61
|
function mobileBridgePath(_projectRoot) {
|
|
61
|
-
return
|
|
62
|
+
return mobileBridgeScriptOverride() ?? "runner:adapters/mobile/bridge-runtime/cdp-bridge.cjs";
|
|
62
63
|
}
|
|
63
64
|
function resolveMobileWaitTarget(payload) {
|
|
64
65
|
const testId = optionalScalarText(
|
|
@@ -1,13 +1,9 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import fs from "node:fs";
|
|
3
|
-
import path from "node:path";
|
|
4
3
|
import { fixtureSummary } from "../doctor.js";
|
|
5
4
|
import { normalizeFeatureFlagRecord } from "../feature-flags.js";
|
|
6
|
-
import {
|
|
5
|
+
import { mobileBridgeScript } from "../adapters/mobile/bridge-script.js";
|
|
7
6
|
const PROBE_TIMEOUT_MS = 2e3;
|
|
8
|
-
function bridgeScriptPath() {
|
|
9
|
-
return process.env.METAMASK_RECIPE_MOBILE_BRIDGE_SCRIPT ?? path.join(runnerDir, "adapters", "mobile", "bridge-runtime", "cdp-bridge.cjs");
|
|
10
|
-
}
|
|
11
7
|
function truncateAddress(address) {
|
|
12
8
|
if (!address || address.length <= 10) return address;
|
|
13
9
|
return `${address.slice(0, 6)}\u2026${address.slice(-4)}`;
|
|
@@ -77,7 +73,7 @@ function matchBridgeEntry(device, entries, devices) {
|
|
|
77
73
|
return null;
|
|
78
74
|
}
|
|
79
75
|
function probeMobileBridge(target) {
|
|
80
|
-
const script =
|
|
76
|
+
const script = mobileBridgeScript();
|
|
81
77
|
if (!fs.existsSync(script)) {
|
|
82
78
|
return Promise.resolve({ ok: false, liveState: "no-bridge" });
|
|
83
79
|
}
|
|
@@ -208,10 +208,11 @@ async function prepareCloseModal(page, partialAmount) {
|
|
|
208
208
|
return confirmation;
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
-
async function proveGlobalCleanup(page, balanceBeforeBaseUnits, toleranceBaseUnits, timeoutMs = 20_000) {
|
|
211
|
+
async function proveGlobalCleanup(page, balanceBeforeBaseUnits, releaseBaseUnits, toleranceBaseUnits, timeoutMs = 20_000) {
|
|
212
212
|
await page.navigateHash('#/perps-home');
|
|
213
213
|
const state = await waitForStableGlobalPerpsHome(page, { timeoutMs, requireZero: true });
|
|
214
|
-
const
|
|
214
|
+
const expectedBalanceAfterBaseUnits = `${BigInt(balanceBeforeBaseUnits) + BigInt(releaseBaseUnits ?? '0')}`;
|
|
215
|
+
const balanceDeltaBaseUnits = absoluteDelta(state.balanceBaseUnits, expectedBalanceAfterBaseUnits);
|
|
215
216
|
if (BigInt(balanceDeltaBaseUnits) > BigInt(toleranceBaseUnits)) {
|
|
216
217
|
throw new Error('Final visible Perps balance exceeds the authorized reconciliation tolerance.');
|
|
217
218
|
}
|
|
@@ -219,6 +220,8 @@ async function proveGlobalCleanup(page, balanceBeforeBaseUnits, toleranceBaseUni
|
|
|
219
220
|
positionCount: 0,
|
|
220
221
|
orderCount: 0,
|
|
221
222
|
balanceBeforeBaseUnits,
|
|
223
|
+
releaseBaseUnits: releaseBaseUnits ?? null,
|
|
224
|
+
expectedBalanceAfterBaseUnits,
|
|
222
225
|
balanceAfterBaseUnits: state.balanceBaseUnits,
|
|
223
226
|
balanceDeltaBaseUnits,
|
|
224
227
|
toleranceBaseUnits,
|
|
@@ -327,9 +330,14 @@ export async function closeVisiblePosition(input) {
|
|
|
327
330
|
if (!/^(?:0|[1-9][0-9]{0,29})$/u.test(balanceBeforeBaseUnits)) {
|
|
328
331
|
throw new Error('Resumed close receipt has no valid pre-order balance.');
|
|
329
332
|
}
|
|
333
|
+
const releaseBaseUnits = existingReceipt.record?.facts?.closeReleaseBaseUnits ?? null;
|
|
334
|
+
if (releaseBaseUnits != null && !/^-?(?:0|[1-9][0-9]{0,29})$/u.test(String(releaseBaseUnits))) {
|
|
335
|
+
throw new Error('Resumed close receipt has an invalid close release amount.');
|
|
336
|
+
}
|
|
330
337
|
const cleanup = await proveGlobalCleanup(
|
|
331
338
|
page,
|
|
332
339
|
balanceBeforeBaseUnits,
|
|
340
|
+
releaseBaseUnits,
|
|
333
341
|
balanceToleranceBaseUnits,
|
|
334
342
|
);
|
|
335
343
|
return {
|
|
@@ -413,6 +421,11 @@ export async function closeVisiblePosition(input) {
|
|
|
413
421
|
if (partialAmount == null && (!confirmation.fullSizeSelected || !confirmation.marketOrder)) {
|
|
414
422
|
throw new Error('Receipt-bound full close requires the visible 100% market-close form.');
|
|
415
423
|
}
|
|
424
|
+
// Without a placement receipt the baseline is the in-position balance, which excludes the released margin.
|
|
425
|
+
const closeReleaseBaseUnits = placement ? null : parseUsdBaseUnits(confirmation.receive);
|
|
426
|
+
if (!placement && closeReleaseBaseUnits == null) {
|
|
427
|
+
throw new Error('Close without a placement receipt requires the visible close receive amount.');
|
|
428
|
+
}
|
|
416
429
|
const closeSizing = partialAmount == null ? { size: sizeMatch[1], side: fundingSide === 'long' ? 'short' : 'long' } : null;
|
|
417
430
|
let submissionObserver = null;
|
|
418
431
|
let mutation;
|
|
@@ -427,6 +440,7 @@ export async function closeVisiblePosition(input) {
|
|
|
427
440
|
cleanupOnly,
|
|
428
441
|
fundingBalance,
|
|
429
442
|
tradingContext,
|
|
443
|
+
...(closeReleaseBaseUnits != null ? { closeReleaseBaseUnits } : {}),
|
|
430
444
|
...(acceptedFill ? { acceptedFill } : {}),
|
|
431
445
|
...(closeSizing ? { closeSizing } : {}),
|
|
432
446
|
},
|
|
@@ -507,6 +521,7 @@ export async function closeVisiblePosition(input) {
|
|
|
507
521
|
const cleanup = await proveGlobalCleanup(
|
|
508
522
|
page,
|
|
509
523
|
balanceBeforeBaseUnits,
|
|
524
|
+
closeReleaseBaseUnits,
|
|
510
525
|
balanceToleranceBaseUnits,
|
|
511
526
|
);
|
|
512
527
|
return {
|
|
@@ -107,6 +107,8 @@ export async function runAdapter(callback) {
|
|
|
107
107
|
}
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
// Same rule as src/adapters/mobile/bridge-script.ts (unset or empty means the
|
|
111
|
+
// bundled bridge); the library cannot import src/.
|
|
110
112
|
function bridgeScript(input) {
|
|
111
113
|
if (process.env.METAMASK_RECIPE_MOBILE_BRIDGE_SCRIPT) {
|
|
112
114
|
return process.env.METAMASK_RECIPE_MOBILE_BRIDGE_SCRIPT;
|
|
@@ -16,6 +16,41 @@ import { mobileToolRecovery, resolveMobileToolPath } from '@farmslot/adapter-rn'
|
|
|
16
16
|
const execFileAsync = promisify(execFile);
|
|
17
17
|
const VISIBLE_LIMIT = 200;
|
|
18
18
|
const HIDDEN_LIMIT = 100;
|
|
19
|
+
// iOS accessibility exposes React Native Text as StaticText without its testID.
|
|
20
|
+
// Read each mounted Perps Text's testID, string and size (synchronous on Fabric)
|
|
21
|
+
// so the visible native element with the same label and size can carry that
|
|
22
|
+
// testID. Only perps- Texts leave the app: other Text, such as a revealed
|
|
23
|
+
// Secret Recovery Phrase word, never reaches the harness or a trace.
|
|
24
|
+
const TEXT_TEST_ID_LIMIT = 500;
|
|
25
|
+
const IOS_TEXT_TEST_IDS = `(() => {
|
|
26
|
+
const ui = globalThis.nativeFabricUIManager;
|
|
27
|
+
const hook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
|
28
|
+
if (!ui?.measureInWindow || !hook?.getFiberRoots) return { texts: [], total: 0 };
|
|
29
|
+
const texts = [];
|
|
30
|
+
const content = (fiber) => {
|
|
31
|
+
let value = '';
|
|
32
|
+
for (let child = fiber.child; child; child = child.sibling) {
|
|
33
|
+
value += typeof child.memoizedProps === 'string' || typeof child.memoizedProps === 'number'
|
|
34
|
+
? String(child.memoizedProps) : content(child);
|
|
35
|
+
}
|
|
36
|
+
return value;
|
|
37
|
+
};
|
|
38
|
+
const visit = (fiber) => {
|
|
39
|
+
for (; fiber; fiber = fiber.sibling) {
|
|
40
|
+
const testId = fiber.memoizedProps?.testID;
|
|
41
|
+
if (fiber.type === 'RCTText' && typeof testId === 'string' && testId.startsWith('perps-') && fiber.stateNode?.node) {
|
|
42
|
+
let bounds;
|
|
43
|
+
ui.measureInWindow(fiber.stateNode.node, (x, y, width, height) => { bounds = { x, y, width, height }; });
|
|
44
|
+
if (bounds) texts.push({ testId, label: content(fiber), bounds });
|
|
45
|
+
}
|
|
46
|
+
visit(fiber.child);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
const roots = new Set();
|
|
50
|
+
for (const id of hook.renderers.keys()) hook.getFiberRoots(id)?.forEach((root) => roots.add(root));
|
|
51
|
+
roots.forEach((root) => visit(root.current));
|
|
52
|
+
return { texts: texts.slice(0, ${TEXT_TEST_ID_LIMIT}), total: texts.length };
|
|
53
|
+
})()`;
|
|
19
54
|
|
|
20
55
|
export async function observeNativeUi(payload, context) {
|
|
21
56
|
await hideAccessibilityMaskingHud(payload, context);
|
|
@@ -39,7 +74,26 @@ export async function observeNativeUi(payload, context) {
|
|
|
39
74
|
warnings.push({ ref: 'ui.screen', message: `Navigation route unavailable: ${error.message}` });
|
|
40
75
|
}
|
|
41
76
|
}
|
|
42
|
-
|
|
77
|
+
let textTestIds = [];
|
|
78
|
+
if (
|
|
79
|
+
supported.includes('ui.visible') &&
|
|
80
|
+
resolvePlatform(record(payload?.node), env) === 'ios' &&
|
|
81
|
+
!isOpaqueRuntime(env) && (env.METRO_PORT || env.WATCHER_PORT)
|
|
82
|
+
) {
|
|
83
|
+
try {
|
|
84
|
+
const read = await bridgeCommand(routeInput, ['eval', IOS_TEXT_TEST_IDS]);
|
|
85
|
+
textTestIds = Array.isArray(read?.texts) ? read.texts : [];
|
|
86
|
+
if (read?.total > textTestIds.length) {
|
|
87
|
+
warnings.push({
|
|
88
|
+
ref: 'ui.visible',
|
|
89
|
+
message: `Text test IDs truncated: read ${textTestIds.length} of ${read.total}.`,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
} catch (error) {
|
|
93
|
+
warnings.push({ ref: 'ui.visible', message: `Text test IDs unavailable: ${error.message}` });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const hierarchy = await readNativeHierarchy(payload, context, textTestIds);
|
|
43
97
|
if (routeBefore?.name) {
|
|
44
98
|
const routeAfter = await bridgeCommand(routeInput, ['get-route']);
|
|
45
99
|
if (routeAfter?.name !== routeBefore.name || routeAfter?.key !== routeBefore.key) {
|
|
@@ -85,7 +139,7 @@ export async function hideAccessibilityMaskingHud(payload, context) {
|
|
|
85
139
|
}
|
|
86
140
|
}
|
|
87
141
|
|
|
88
|
-
async function readNativeHierarchy(payload, context) {
|
|
142
|
+
async function readNativeHierarchy(payload, context, textTestIds) {
|
|
89
143
|
const node = record(payload?.node);
|
|
90
144
|
const env = {
|
|
91
145
|
...process.env,
|
|
@@ -98,13 +152,13 @@ async function readNativeHierarchy(payload, context) {
|
|
|
98
152
|
const appLabel = nativeAppLabel(env);
|
|
99
153
|
const hierarchy = platform === 'android'
|
|
100
154
|
? await readAndroidHierarchy(node, env)
|
|
101
|
-
: await readIosHierarchy(node, env);
|
|
155
|
+
: await readIosHierarchy(node, env, textTestIds);
|
|
102
156
|
// No screen name and no labelled item: name the screen after the app.
|
|
103
157
|
hierarchy.screen.name ??= appLabel;
|
|
104
158
|
return hierarchy;
|
|
105
159
|
}
|
|
106
160
|
|
|
107
|
-
async function readIosHierarchy(node, env) {
|
|
161
|
+
async function readIosHierarchy(node, env, textTestIds) {
|
|
108
162
|
const idbPath = resolveMobileToolPath('idb');
|
|
109
163
|
if (!idbPath) {
|
|
110
164
|
throw new Error(
|
|
@@ -123,7 +177,7 @@ async function readIosHierarchy(node, env) {
|
|
|
123
177
|
},
|
|
124
178
|
);
|
|
125
179
|
const parsed = parseJsonOutput(stdout);
|
|
126
|
-
const hierarchy = flattenIosHierarchy(parsed);
|
|
180
|
+
const hierarchy = flattenIosHierarchy(parsed, textTestIds);
|
|
127
181
|
if (hierarchy.nodeCount === 0) {
|
|
128
182
|
throw new Error('idb returned an empty accessibility hierarchy.');
|
|
129
183
|
}
|
|
@@ -364,7 +418,12 @@ function parseJsonOutput(output) {
|
|
|
364
418
|
}
|
|
365
419
|
}
|
|
366
420
|
|
|
367
|
-
function flattenIosHierarchy(value) {
|
|
421
|
+
function flattenIosHierarchy(value, textTestIds = []) {
|
|
422
|
+
const texts = (Array.isArray(textTestIds) ? textTestIds : []).map((entry) => ({
|
|
423
|
+
testId: text(entry?.testId),
|
|
424
|
+
label: text(entry?.label),
|
|
425
|
+
bounds: normalizeBounds(entry?.bounds),
|
|
426
|
+
})).filter((entry) => entry.testId && entry.label && entry.bounds);
|
|
368
427
|
const visible = [];
|
|
369
428
|
const hidden = [];
|
|
370
429
|
let nodeCount = 0;
|
|
@@ -382,9 +441,10 @@ function flattenIosHierarchy(value) {
|
|
|
382
441
|
text(item.role_description) ?? text(item.className);
|
|
383
442
|
const label = text(item.label) ?? text(item.AXLabel) ?? text(item.name) ??
|
|
384
443
|
text(item.title);
|
|
385
|
-
const testId = text(item.identifier) ?? text(item.accessibilityIdentifier) ??
|
|
386
|
-
text(item.AXUniqueId) ?? text(item.id);
|
|
387
444
|
const bounds = normalizeBounds(item.frame ?? item.AXFrame ?? item.bounds);
|
|
445
|
+
const testId = text(item.identifier) ?? text(item.accessibilityIdentifier) ??
|
|
446
|
+
text(item.AXUniqueId) ?? text(item.id) ??
|
|
447
|
+
(label && hasVisibleBounds(bounds, screenBounds) ? textTestId(texts, label, bounds) : undefined);
|
|
388
448
|
if (isApplicationRole(role)) {
|
|
389
449
|
screenName ??= label;
|
|
390
450
|
screenBounds ??= bounds;
|
|
@@ -466,6 +526,17 @@ function parseAndroidHierarchy(output) {
|
|
|
466
526
|
return { visible, hidden, nodeCount, screenName };
|
|
467
527
|
}
|
|
468
528
|
|
|
529
|
+
// Bind an exact label and size to one testID. Origins are not compared: Fabric
|
|
530
|
+
// frames omit native screen offsets such as a modal card. A same-text twin with
|
|
531
|
+
// another size (the collapsed header price) or another testID stays unbound.
|
|
532
|
+
function textTestId(texts, label, bounds) {
|
|
533
|
+
const testIds = new Set(texts
|
|
534
|
+
.filter((entry) => entry.label === label &&
|
|
535
|
+
['width', 'height'].every((key) => Math.abs(entry.bounds[key] - bounds[key]) <= 1))
|
|
536
|
+
.map((entry) => entry.testId));
|
|
537
|
+
return testIds.size === 1 ? [...testIds][0] : undefined;
|
|
538
|
+
}
|
|
539
|
+
|
|
469
540
|
function isApplicationRole(role) {
|
|
470
541
|
return role?.toLowerCase().includes('application') ?? false;
|
|
471
542
|
}
|