@deeeed/metamask-harness 0.8.0 → 0.9.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 +14 -0
- package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +23 -5
- package/adapters/mobile/bridge-runtime/lib/config.cjs +14 -2
- package/adapters/mobile/bridge-runtime/lib/target-discovery.cjs +107 -19
- package/adapters/mobile/start-metro.sh +23 -6
- package/dist/cli-commands.js +1 -1
- package/dist/commands/parse-args.js +1 -0
- package/dist/commands/status-probe.js +167 -0
- package/dist/commands/status.js +58 -1
- package/dist/mm-harness-cli.js +18 -5
- package/docs/CLI-SPEC.md +42 -5
- package/library/actions/mobile/platform/bridge.mjs +81 -4
- package/library/actions/mobile/wallet/setup.mjs +5 -2
- package/library/manifests/core.action-manifest.json +1465 -1225
- package/library/manifests/extension.action-manifest.json +324 -5
- package/library/manifests/mobile.action-manifest.json +321 -4
- package/package.json +1 -1
package/docs/CLI-SPEC.md
CHANGED
|
@@ -439,7 +439,9 @@ Readiness check for a checkout without launching the app. Doctor is the single p
|
|
|
439
439
|
|
|
440
440
|
## `--device <id>` — first-class mobile device targeting (REAL)
|
|
441
441
|
|
|
442
|
-
`--device` is the uniform mobile device selector on `run`, `call`, and `doctor`.
|
|
442
|
+
`--device` is the uniform mobile device selector on `run`, `call`, and `doctor`. Pass the **adb serial** for Android (from `adb devices`) or the **UDID / simulator name** for iOS. The harness resolves the adb serial to the Metro CDP target identity internally — users never need to know or set `ANDROID_DEVICE='Pixel 6 - 16 - API 36'`.
|
|
443
|
+
|
|
444
|
+
**Internal Android identity mapping**: `ADB_SERIAL` / `ANDROID_SERIAL` carry the raw adb serial. The bridge resolves the device model via `adb -s <serial> shell getprop ro.product.model` and propagates it as `ANDROID_TARGET_DEVICE_NAME`. Target-discovery uses `ANDROID_TARGET_DEVICE_NAME` for Metro `deviceName` prefix matching (e.g. `"Pixel 6"` matches `"Pixel 6 - 16 - API 36"`). When the pinned model cannot be matched to any Metro `/json/list` candidate and multiple candidates exist, the bridge fails fast with a diagnostic listing every candidate's `deviceName` — it never silently selects the wrong device. iOS UDID/simulator name → `IOS_SIMULATOR` (unchanged).
|
|
443
445
|
|
|
444
446
|
| Verb | `--device` given | `--device` omitted (mobile) |
|
|
445
447
|
|---|---|---|
|
|
@@ -450,7 +452,7 @@ Readiness check for a checkout without launching the app. Doctor is the single p
|
|
|
450
452
|
|
|
451
453
|
## `status` (aliases `health`, `home`) — home dashboard + devices[] (REAL)
|
|
452
454
|
|
|
453
|
-
`status` is the compact home dashboard for a checkout: the detected adapter, the next command to run (`next`), and — for **mobile** checkouts — a `devices[]` section. Extension/core checkouts report an empty `devices[]
|
|
455
|
+
`status` is the compact home dashboard for a checkout: the detected adapter, the next command to run (`next`), and — for **mobile** checkouts — a `devices[]` section with live app state. Extension/core checkouts report an empty `devices[]`. Additive envelope:
|
|
454
456
|
|
|
455
457
|
```json
|
|
456
458
|
{
|
|
@@ -459,14 +461,49 @@ Readiness check for a checkout without launching the app. Doctor is the single p
|
|
|
459
461
|
"adapter": "mobile",
|
|
460
462
|
"target": "/path/to/checkout",
|
|
461
463
|
"devices": [
|
|
462
|
-
{
|
|
463
|
-
|
|
464
|
+
{
|
|
465
|
+
"platform": "android", "id": "emulator-5554", "name": "Pixel_6",
|
|
466
|
+
"state": "device", "selected": true,
|
|
467
|
+
"currentScreen": "Wallet", "walletState": "unlocked",
|
|
468
|
+
"selectedAccount": { "label": "Account 1", "address": "0xabcd…ef12" },
|
|
469
|
+
"fixtureStatus": "READY"
|
|
470
|
+
},
|
|
471
|
+
{
|
|
472
|
+
"platform": "ios", "id": "AAAA-BBBB", "name": "iPhone 15",
|
|
473
|
+
"state": "Booted", "selected": false,
|
|
474
|
+
"fixtureStatus": "READY", "liveState": "no-bridge"
|
|
475
|
+
}
|
|
464
476
|
],
|
|
465
477
|
"next": "mm-harness launch ios"
|
|
466
478
|
}
|
|
467
479
|
```
|
|
468
480
|
|
|
469
|
-
|
|
481
|
+
**Per-device live fields** (additive — absent or null when unreachable):
|
|
482
|
+
|
|
483
|
+
| Field | Type | Description |
|
|
484
|
+
|---|---|---|
|
|
485
|
+
| `currentScreen` | string | Active route/screen in the running app (mobile bridge `status` route field) |
|
|
486
|
+
| `walletState` | `'locked'\|'unlocked'\|'onboarding'` | Derived from bridge status: account present → unlocked; onboarding route → onboarding; else locked |
|
|
487
|
+
| `selectedAccount` | `{ label, address }` | Active account name + address truncated to `0x1234…abcd` form |
|
|
488
|
+
| `fixtureStatus` | `'READY'\|'missing'` | Checkout-level: whether `wallet-fixture.json` is present and well-formed |
|
|
489
|
+
| `liveState` | `'no-bridge'\|'bridge-absent'` | Set when the bridge is unreachable/times out (`no-bridge`) or the target answers but `__AGENTIC__` is absent (`bridge-absent`); live fields omitted in both cases |
|
|
490
|
+
|
|
491
|
+
**`--fast` flag:** skips all live-state probes. Output is instant (same envelope shape, live fields absent). Safe for scripts and CI where probe latency is unacceptable.
|
|
492
|
+
|
|
493
|
+
**Human output** is progressive: static info (adapter, device list, next) prints immediately; a `live:` block appends after the ~2s probe window closes. `--fast` suppresses the live block entirely.
|
|
494
|
+
|
|
495
|
+
`selected` is `true` when the device id matches `ADB_SERIAL`/`ANDROID_SERIAL` (android) or `IOS_SIMULATOR` (ios) env. `doctor --json` carries the same `devices[]` shape (with `selected`) for mobile checkouts.
|
|
496
|
+
|
|
497
|
+
The mobile bridge probes all connected RN targets in one call and returns an array; each device is matched to its entry by name or platform-uniqueness, so multi-device checkouts (e.g. Android + iOS simulator) receive per-device live state.
|
|
498
|
+
|
|
499
|
+
`liveState` in the JSON envelope (absent = fully enriched):
|
|
500
|
+
|
|
501
|
+
| Value | Meaning |
|
|
502
|
+
|---|---|
|
|
503
|
+
| `'no-bridge'` | No matching/responding target on Metro — bridge genuinely unreachable or app not running. |
|
|
504
|
+
| `'bridge-absent'` | Target IS reachable and answered Runtime.evaluate, but `typeof globalThis.__AGENTIC__ === 'undefined'` — the installed build predates the bridge. Reinstall a dev build to get live state. |
|
|
505
|
+
|
|
506
|
+
Extension/core live-state probing (CDP home-tab route) is V1-pending — use `doctor` for runtime status.
|
|
470
507
|
|
|
471
508
|
**Exit:** 0 (2 when the repo type cannot be detected).
|
|
472
509
|
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
-
import { spawn } from 'node:child_process';
|
|
2
|
+
import { execFile, spawn } from 'node:child_process';
|
|
3
|
+
import { promisify } from 'node:util';
|
|
3
4
|
import path from 'node:path';
|
|
4
5
|
import { fileURLToPath } from 'node:url';
|
|
5
6
|
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
|
|
6
9
|
function sleep(ms) {
|
|
7
10
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
8
11
|
}
|
|
@@ -34,7 +37,30 @@ function runtimeDir() {
|
|
|
34
37
|
return fileURLToPath(new URL('../../../../adapters/mobile/bridge-runtime', import.meta.url));
|
|
35
38
|
}
|
|
36
39
|
|
|
37
|
-
|
|
40
|
+
/**
|
|
41
|
+
* Resolve the Android model name from an adb serial.
|
|
42
|
+
* Returns the trimmed ro.product.model value, or null when adb is unavailable
|
|
43
|
+
* or the serial does not respond. Uses execFile (never shell interpolation).
|
|
44
|
+
*/
|
|
45
|
+
async function resolveAndroidModel(adbSerial) {
|
|
46
|
+
try {
|
|
47
|
+
const { stdout } = await execFileAsync(
|
|
48
|
+
'adb',
|
|
49
|
+
['-s', adbSerial, 'shell', 'getprop', 'ro.product.model'],
|
|
50
|
+
{ timeout: 5000, encoding: 'utf8' },
|
|
51
|
+
);
|
|
52
|
+
const model = stdout.trim();
|
|
53
|
+
return model || null;
|
|
54
|
+
} catch {
|
|
55
|
+
// Recovery is correct: model resolution for Metro target selection is advisory.
|
|
56
|
+
// If adb is unavailable or the serial is unreachable, discovery falls through to
|
|
57
|
+
// the exact ANDROID_DEVICE match or probe-and-pick. The error will surface from
|
|
58
|
+
// the CDP connection attempt if the wrong target is selected.
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function bridgeEnv(input) {
|
|
38
64
|
/** @type {NodeJS.ProcessEnv} */
|
|
39
65
|
const env = {
|
|
40
66
|
...process.env,
|
|
@@ -53,6 +79,20 @@ export function bridgeEnv(input) {
|
|
|
53
79
|
env.ADB_SERIAL = String(adbSerial);
|
|
54
80
|
env.ANDROID_SERIAL = String(adbSerial);
|
|
55
81
|
}
|
|
82
|
+
// When --device passes an adb serial, device-target.ts sets both ANDROID_DEVICE and
|
|
83
|
+
// ADB_SERIAL to the same value. ANDROID_DEVICE must be the Metro deviceName for
|
|
84
|
+
// target-discovery to select the right CDP target; it cannot be a raw adb serial.
|
|
85
|
+
// Resolve the device model via adb getprop and propagate it as
|
|
86
|
+
// ANDROID_TARGET_DEVICE_NAME so target-discovery can match by model prefix
|
|
87
|
+
// (e.g. "Pixel 6" matches Metro deviceName "Pixel 6 - 16 - API 36").
|
|
88
|
+
const serialStr = adbSerial != null ? String(adbSerial) : '';
|
|
89
|
+
const deviceStr = androidDevice != null ? String(androidDevice) : '';
|
|
90
|
+
if (serialStr && deviceStr === serialStr) {
|
|
91
|
+
const model = await resolveAndroidModel(serialStr);
|
|
92
|
+
if (model) {
|
|
93
|
+
env.ANDROID_TARGET_DEVICE_NAME = model;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
56
96
|
return env;
|
|
57
97
|
}
|
|
58
98
|
|
|
@@ -65,13 +105,20 @@ function resolveMobileTarget(input) {
|
|
|
65
105
|
return { watcherPort, iosSimulator, androidDevice, adbSerial };
|
|
66
106
|
}
|
|
67
107
|
|
|
108
|
+
// Commands where a transient undefined/empty stdout means "not yet settled", not failure.
|
|
109
|
+
// get-route returns undefined mid-navigation when the route state is momentarily unavailable;
|
|
110
|
+
// waitForRoute polls through these nulls rather than aborting on a parse error.
|
|
111
|
+
const TRANSIENT_NULL_COMMANDS = new Set(['get-route']);
|
|
112
|
+
|
|
68
113
|
export async function bridgeCommand(input, args) {
|
|
69
114
|
const script = bridgeScript(input);
|
|
115
|
+
// bridgeEnv is async: it may call `adb getprop` to resolve the Metro device name.
|
|
116
|
+
const env = await bridgeEnv(input);
|
|
70
117
|
const result = await new Promise((resolve, reject) => {
|
|
71
118
|
const timeoutMs = Number(input.node?.bridge_timeout_ms ?? input.node?.cdp_timeout_ms ?? process.env.CDP_TIMEOUT ?? 30000);
|
|
72
119
|
const child = spawn(process.execPath, [script, ...args], {
|
|
73
120
|
cwd: input.context.projectRoot,
|
|
74
|
-
env: { ...
|
|
121
|
+
env: { ...env, APP_ROOT: input.context.projectRoot },
|
|
75
122
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
76
123
|
});
|
|
77
124
|
let stdout = '';
|
|
@@ -111,6 +158,14 @@ export async function bridgeCommand(input, args) {
|
|
|
111
158
|
const command = ['node', path.relative(input.context.projectRoot, script), ...redactBridgeArgs(args)].join(' ');
|
|
112
159
|
throw new Error(`Mobile CDP bridge command failed: ${command}\n${redactBridgeOutput(result.stderr || result.stdout, sensitiveBridgeArgs(args))}`);
|
|
113
160
|
}
|
|
161
|
+
// For transient commands, stdout of '' or 'undefined' means the state is not yet
|
|
162
|
+
// settled (e.g. mid-navigation). Return null so callers like waitForRoute can poll
|
|
163
|
+
// rather than aborting on a JSON parse error.
|
|
164
|
+
const command = String(args[0] ?? '');
|
|
165
|
+
const trimmedStdout = result.stdout.trim();
|
|
166
|
+
if ((trimmedStdout === '' || trimmedStdout === 'undefined') && TRANSIENT_NULL_COMMANDS.has(command)) {
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
114
169
|
try {
|
|
115
170
|
const parsed = JSON.parse(result.stdout);
|
|
116
171
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed) && parsed.ok === false) {
|
|
@@ -195,13 +250,35 @@ function routeName(route) {
|
|
|
195
250
|
export async function waitForRoute(input, expectedRoute, timeoutMs = 15000) {
|
|
196
251
|
const expected = String(expectedRoute);
|
|
197
252
|
const deadline = Date.now() + timeoutMs;
|
|
253
|
+
// lastRoute is null when bridgeCommand returns null (transient: route not yet
|
|
254
|
+
// settled mid-navigation). Null means "not ready yet" — keep polling.
|
|
198
255
|
let lastRoute = null;
|
|
256
|
+
let pollCount = 0;
|
|
199
257
|
while (Date.now() < deadline) {
|
|
200
258
|
lastRoute = await bridgeCommand(input, ['get-route']);
|
|
201
259
|
if (routeName(lastRoute) === expected) return lastRoute;
|
|
260
|
+
pollCount += 1;
|
|
202
261
|
await sleep(250);
|
|
203
262
|
}
|
|
204
|
-
|
|
263
|
+
const target = resolveMobileTarget(input);
|
|
264
|
+
const deviceHint = [
|
|
265
|
+
target.adbSerial && `ADB_SERIAL=${target.adbSerial}`,
|
|
266
|
+
target.androidDevice && target.androidDevice !== target.adbSerial && `ANDROID_DEVICE=${target.androidDevice}`,
|
|
267
|
+
target.iosSimulator && `IOS_SIMULATOR=${target.iosSimulator}`,
|
|
268
|
+
].filter(Boolean).join(', ') || 'no device pin';
|
|
269
|
+
// bridgeCommand already parsed (or null-normalized) the reply, so raw stdout is
|
|
270
|
+
// not available here — describe the reply honestly instead of relabeling the
|
|
271
|
+
// parsed value as "raw".
|
|
272
|
+
const lastReply = lastRoute === null
|
|
273
|
+
? 'empty/undefined (route transiently unavailable — bridge not yet settled)'
|
|
274
|
+
: JSON.stringify(lastRoute);
|
|
275
|
+
throw new Error(
|
|
276
|
+
`Timed out waiting for Mobile route '${expected}' after ${timeoutMs}ms (${pollCount} polls).\n` +
|
|
277
|
+
` Expected route: ${expected}\n` +
|
|
278
|
+
` Last parsed route: ${JSON.stringify(lastRoute)}\n` +
|
|
279
|
+
` Last bridge reply: ${lastReply}\n` +
|
|
280
|
+
` Device: ${deviceHint}`,
|
|
281
|
+
);
|
|
205
282
|
}
|
|
206
283
|
|
|
207
284
|
export async function simulatorScreenshot(input, relPath) {
|
|
@@ -112,7 +112,10 @@ function setupWalletScript() {
|
|
|
112
112
|
return fileURLToPath(new URL('../../../../adapters/mobile/bridge-runtime/setup-wallet.sh', import.meta.url));
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
-
function runSetupWallet(input, fixture) {
|
|
115
|
+
async function runSetupWallet(input, fixture) {
|
|
116
|
+
// bridgeEnv is async (it may shell adb to resolve the Metro device name);
|
|
117
|
+
// spreading its un-awaited Promise would hand the child an almost-empty env.
|
|
118
|
+
const env = await bridgeEnv(input);
|
|
116
119
|
return new Promise((resolve, reject) => {
|
|
117
120
|
const timeoutMs = Number(
|
|
118
121
|
input.node?.setup_timeout_ms ?? input.node?.timeout_ms ?? 120000,
|
|
@@ -122,7 +125,7 @@ function runSetupWallet(input, fixture) {
|
|
|
122
125
|
[setupWalletScript(), '--fixture', fixture.absolutePath],
|
|
123
126
|
{
|
|
124
127
|
cwd: input.context.projectRoot,
|
|
125
|
-
env: { ...
|
|
128
|
+
env: { ...env, CDP_TIMEOUT: String(timeoutMs), APP_ROOT: input.context.projectRoot },
|
|
126
129
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
127
130
|
},
|
|
128
131
|
);
|