@deeeed/metamask-harness 0.9.1 → 0.11.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/CHANGELOG.md +30 -0
- package/adapters/mobile/bridge-runtime/lib/target-discovery.cjs +24 -16
- package/adapters/mobile/start-metro.sh +80 -36
- package/adapters/mobile/wait-for-bridge.sh +15 -1
- package/dist/adapters/extension/runtime.js +2 -3
- package/dist/adapters.js +41 -18
- package/dist/app-lifecycle.js +72 -0
- package/dist/cli-commands.js +1 -1
- package/dist/commands/device-target.js +39 -9
- package/dist/commands/fixtures.js +13 -1
- package/dist/commands/launch/index.js +35 -7
- package/dist/commands/run-engine.js +40 -12
- package/dist/commands/run.js +1 -2
- package/dist/live-adapter-contract.js +2 -3
- package/dist/mm-harness-cli.js +1 -0
- package/dist/paths.js +8 -1
- package/dist/runner.js +10 -4
- package/docs/CLI-SPEC.md +2 -1
- package/docs/recipe-libraries.md +203 -0
- package/library/actions/mobile/platform/bridge.mjs +91 -4
- package/library/actions/mobile/wallet/ensure_unlocked.mjs +69 -32
- package/library/actions/mobile/wallet/read_state.mjs +3 -24
- package/library/actions/mobile/wallet/setup.mjs +9 -27
- package/library/manifests/extension.action-manifest.json +13 -0
- package/library/manifests/mobile.action-manifest.json +31 -0
- package/library/recipes/app-lifecycle-android-smoke.mobile.recipe.json +87 -0
- package/library/recipes/perps-performance-background-resume.mobile.recipe.json +72 -0
- package/library/recipes/perps-performance-cold-start.mobile.recipe.json +72 -0
- package/library/recipes/perps-performance-warm-start.mobile.recipe.json +64 -0
- package/library/recipes/perps-performance.mobile.recipe.json +56 -0
- package/package.json +2 -2
|
@@ -97,6 +97,7 @@ function recipeRunEnv(adapter, runtimeOptions = {}) {
|
|
|
97
97
|
METRO_PORT: process.env.METRO_PORT ?? process.env.WATCHER_PORT ?? base.CDP_PORT ?? base.RECIPE_CDP_PORT,
|
|
98
98
|
IOS_SIMULATOR: process.env.IOS_SIMULATOR,
|
|
99
99
|
ANDROID_DEVICE: process.env.ANDROID_DEVICE,
|
|
100
|
+
ANDROID_TARGET_DEVICE_NAME: process.env.ANDROID_TARGET_DEVICE_NAME,
|
|
100
101
|
ADB_SERIAL: process.env.ADB_SERIAL,
|
|
101
102
|
ANDROID_SERIAL: process.env.ANDROID_SERIAL
|
|
102
103
|
};
|
|
@@ -139,19 +140,30 @@ function isRecipeFile(p) {
|
|
|
139
140
|
return false;
|
|
140
141
|
}
|
|
141
142
|
}
|
|
142
|
-
function resolveRunRecipeArg(recipeArg, adapter) {
|
|
143
|
+
function resolveRunRecipeArg(recipeArg, adapter, librarySources) {
|
|
143
144
|
const direct = path.resolve(recipeArg);
|
|
144
145
|
if (isRecipeFile(direct)) return { recipeFile: direct };
|
|
145
146
|
if (!recipeArg.includes("/") && !recipeArg.includes(path.sep)) {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
147
|
+
const candidates = [`${recipeArg}.${adapter}.recipe.json`, `${recipeArg}.recipe.json`, recipeArg];
|
|
148
|
+
if (librarySources && librarySources.length > 0) {
|
|
149
|
+
for (const source of librarySources) {
|
|
150
|
+
for (const candidate of candidates) {
|
|
151
|
+
const file = path.join(source.root, "recipes", candidate);
|
|
152
|
+
if (isRecipeFile(file)) return { recipeFile: file };
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
} else {
|
|
156
|
+
for (const candidate of candidates) {
|
|
157
|
+
const file = recipePath(candidate);
|
|
158
|
+
if (isRecipeFile(file)) return { recipeFile: file };
|
|
159
|
+
}
|
|
149
160
|
}
|
|
150
161
|
}
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
}
|
|
162
|
+
const packagedNames = libraryRecipeNames(adapter);
|
|
163
|
+
const nonCanonical = librarySources?.filter((s) => s.name !== "metamask") ?? [];
|
|
164
|
+
const notFoundCore = nonCanonical.length > 0 ? `recipe not found: ${recipeArg} \u2014 not a file, and no recipe matched in library sources [${librarySources.map((s) => s.name ?? path.basename(s.root)).join(", ")}].` : `recipe not found: ${recipeArg} \u2014 not a file, and no packaged library recipe matched.`;
|
|
165
|
+
const suffix = packagedNames.length > 0 ? ` Library recipes for ${adapter}: ${packagedNames.join(", ")} (mm-harness run <name>).` : ` The packaged library has no recipes for ${adapter}.`;
|
|
166
|
+
return { notFound: notFoundCore + suffix };
|
|
155
167
|
}
|
|
156
168
|
function libraryRecipeNames(adapter) {
|
|
157
169
|
let entries;
|
|
@@ -168,9 +180,18 @@ function libraryRecipeNames(adapter) {
|
|
|
168
180
|
return [...new Set(names)].sort();
|
|
169
181
|
}
|
|
170
182
|
async function validateRunRecipeStatic(recipeArg, adapter, options) {
|
|
171
|
-
const
|
|
183
|
+
const librarySources = await resolveMetaMaskLibrarySources(optionString(options, "library"));
|
|
184
|
+
const resolved = resolveRunRecipeArg(recipeArg, adapter, librarySources);
|
|
172
185
|
const recipeFile = "recipeFile" in resolved ? resolved.recipeFile : path.resolve(recipeArg);
|
|
173
|
-
const empty = {
|
|
186
|
+
const empty = {
|
|
187
|
+
recipe: void 0,
|
|
188
|
+
recipeFile,
|
|
189
|
+
findings: [],
|
|
190
|
+
errorCount: 0,
|
|
191
|
+
manifestOk: false,
|
|
192
|
+
schemaValid: false,
|
|
193
|
+
...librarySources ? { librarySources } : {}
|
|
194
|
+
};
|
|
174
195
|
if ("notFound" in resolved) {
|
|
175
196
|
return { ...empty, usageError: { code: "RECIPE_NOT_FOUND", message: resolved.notFound } };
|
|
176
197
|
}
|
|
@@ -200,11 +221,18 @@ async function validateRunRecipeStatic(recipeArg, adapter, options) {
|
|
|
200
221
|
message: error instanceof Error ? error.message : String(error)
|
|
201
222
|
});
|
|
202
223
|
}
|
|
203
|
-
const librarySources = await resolveMetaMaskLibrarySources(optionString(options, "library"));
|
|
204
224
|
const validation = manifestOk ? await validateRecipeAdapterAware(recipe, manifest, librarySources) : { status: "invalid", findings: [], summary: { errors: 1, warnings: 0 } };
|
|
205
225
|
findings.push(...validation.findings);
|
|
206
226
|
const errorCount = findings.filter((finding) => finding.severity === "error").length;
|
|
207
|
-
return {
|
|
227
|
+
return {
|
|
228
|
+
recipe,
|
|
229
|
+
recipeFile,
|
|
230
|
+
findings,
|
|
231
|
+
errorCount,
|
|
232
|
+
manifestOk,
|
|
233
|
+
schemaValid: validation.status === "valid",
|
|
234
|
+
...librarySources ? { librarySources } : {}
|
|
235
|
+
};
|
|
208
236
|
}
|
|
209
237
|
async function resolveMetaMaskLibrarySources(libraryEntry) {
|
|
210
238
|
const harness = await importRecipeHarness();
|
package/dist/commands/run.js
CHANGED
|
@@ -15,7 +15,6 @@ import {
|
|
|
15
15
|
emitHealViolation,
|
|
16
16
|
executeWithHealBounds,
|
|
17
17
|
prepareHeal,
|
|
18
|
-
resolveMetaMaskLibrarySources,
|
|
19
18
|
runRecipe,
|
|
20
19
|
validateRunRecipeStatic
|
|
21
20
|
} from "./run-engine.js";
|
|
@@ -43,7 +42,7 @@ async function handleRun({ positional, options }) {
|
|
|
43
42
|
if (validated.errorCount > 0) {
|
|
44
43
|
return emitRunValidationError(json, adapter, validated.recipeFile, validated.findings, validated.errorCount);
|
|
45
44
|
}
|
|
46
|
-
const librarySources =
|
|
45
|
+
const librarySources = validated.librarySources;
|
|
47
46
|
const runtimeOptions = {
|
|
48
47
|
...runtimeOptionsFromCli(options),
|
|
49
48
|
...librarySources ? { librarySources } : {},
|
|
@@ -100,9 +100,8 @@ function runProcess(command, args, options) {
|
|
|
100
100
|
if (settled) return;
|
|
101
101
|
settled = true;
|
|
102
102
|
child.kill("SIGTERM");
|
|
103
|
-
setTimeout(() =>
|
|
104
|
-
|
|
105
|
-
}, 1e3);
|
|
103
|
+
const killTimer = setTimeout(() => child.kill("SIGKILL"), 1e3);
|
|
104
|
+
child.once("close", () => clearTimeout(killTimer));
|
|
106
105
|
resolve({ exitCode: null, stdout, stderr, timedOut: true });
|
|
107
106
|
}, options.timeoutMs) : void 0;
|
|
108
107
|
child.on("error", (error) => {
|
package/dist/mm-harness-cli.js
CHANGED
|
@@ -411,6 +411,7 @@ Example:
|
|
|
411
411
|
--extension-id-file <path> finalize: optional file to read/write the resolved extension id
|
|
412
412
|
--adapter <mobile|extension> Target adapter (auto-detected inside a checkout)
|
|
413
413
|
--target <path> Checkout path (default: cwd)
|
|
414
|
+
--device <udid|serial|name> Mobile only: target this device for sync/set
|
|
414
415
|
--json Machine-readable output
|
|
415
416
|
|
|
416
417
|
Example:
|
package/dist/paths.js
CHANGED
|
@@ -113,6 +113,12 @@ async function importRecipeHarnessRuntimeCdp() {
|
|
|
113
113
|
"packages/recipe-harness/src/runtime/cdp.ts"
|
|
114
114
|
);
|
|
115
115
|
}
|
|
116
|
+
async function importRecipeHarnessAppLifecycle() {
|
|
117
|
+
return importProtocolPackage(
|
|
118
|
+
"@farmslot/recipe-harness/adapters/app-lifecycle",
|
|
119
|
+
"packages/recipe-harness/src/adapters/app-lifecycle.ts"
|
|
120
|
+
);
|
|
121
|
+
}
|
|
116
122
|
async function importRecipeHarnessRuntimeBrowserExtension() {
|
|
117
123
|
return importProtocolPackage(
|
|
118
124
|
"@farmslot/recipe-harness/runtime/browser-extension",
|
|
@@ -154,7 +160,7 @@ async function importProtocolPackage(packageName, localSourceEntry) {
|
|
|
154
160
|
function isMissingPackageError(error, packageName) {
|
|
155
161
|
if (!(error instanceof Error)) return false;
|
|
156
162
|
const code = error.code;
|
|
157
|
-
return code === "ERR_MODULE_NOT_FOUND" && error.message.includes(packageName);
|
|
163
|
+
return (code === "ERR_MODULE_NOT_FOUND" || code === "ERR_PACKAGE_PATH_NOT_EXPORTED") && error.message.includes(packageName);
|
|
158
164
|
}
|
|
159
165
|
export {
|
|
160
166
|
DEFAULT_RECIPE_HARNESS_ROOT,
|
|
@@ -162,6 +168,7 @@ export {
|
|
|
162
168
|
assertAdapter,
|
|
163
169
|
extensionIdPath,
|
|
164
170
|
importRecipeHarness,
|
|
171
|
+
importRecipeHarnessAppLifecycle,
|
|
165
172
|
importRecipeHarnessCli,
|
|
166
173
|
importRecipeHarnessRuntimeBrowserExtension,
|
|
167
174
|
importRecipeHarnessRuntimeCdp,
|
package/dist/runner.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { execSync } from "node:child_process";
|
|
2
2
|
import { createMetaMaskAdapters, createMetaMaskUiTransport } from "./adapters.js";
|
|
3
|
+
import { resolveMetaMaskMobileLifecycleTarget } from "./app-lifecycle.js";
|
|
3
4
|
import { loadMetaMaskExtensionActionManifest, loadMetaMaskMobileActionManifest } from "./manifest.js";
|
|
4
5
|
import { createMetaMaskRecordingTargetProvider } from "./recording-target.js";
|
|
5
|
-
import { importRecipeHarness, importRecipeHarnessRuntimeCdp, importRecipeHarnessRuntimeReactNativeBridge, runnerDir } from "./paths.js";
|
|
6
|
+
import { importRecipeHarness, importRecipeHarnessAppLifecycle, importRecipeHarnessRuntimeCdp, importRecipeHarnessRuntimeReactNativeBridge, runnerDir } from "./paths.js";
|
|
6
7
|
async function createMetaMaskMobileRunner(options = {}) {
|
|
7
8
|
return createMetaMaskRunner(
|
|
8
9
|
"mobile",
|
|
@@ -23,13 +24,14 @@ async function createMetaMaskRunner(adapter, actionManifest, options = {}) {
|
|
|
23
24
|
} = await importRecipeHarness();
|
|
24
25
|
const { createCdpWebUiTransport } = await importRecipeHarnessRuntimeCdp();
|
|
25
26
|
const { createReactNativeBridgeUiTransport } = await importRecipeHarnessRuntimeReactNativeBridge();
|
|
27
|
+
const { createAppLifecycleAdapters } = await importRecipeHarnessAppLifecycle();
|
|
26
28
|
const actions = [
|
|
27
29
|
...actionManifest.supported_official_actions,
|
|
28
30
|
...(actionManifest.custom_actions ?? []).map((entry) => entry.name)
|
|
29
31
|
];
|
|
30
32
|
const declaredActions = new Set(actions);
|
|
31
33
|
const core = createStandardCoreAdapters({ actions });
|
|
32
|
-
const projectOwnedOfficialActions = /* @__PURE__ */ new Set(["app.status"]);
|
|
34
|
+
const projectOwnedOfficialActions = /* @__PURE__ */ new Set(["app.status", "app.lifecycle"]);
|
|
33
35
|
const ui = createStandardUiAdapters({
|
|
34
36
|
actions: actions.filter((action) => !projectOwnedOfficialActions.has(action)),
|
|
35
37
|
transport: createMetaMaskUiTransport(adapter, {
|
|
@@ -38,14 +40,18 @@ async function createMetaMaskRunner(adapter, actionManifest, options = {}) {
|
|
|
38
40
|
})
|
|
39
41
|
});
|
|
40
42
|
const existing = new Set([...core, ...ui].map((entry) => entry.action));
|
|
43
|
+
const lifecycle = adapter === "mobile" ? createAppLifecycleAdapters({
|
|
44
|
+
actions,
|
|
45
|
+
targetProvider: { resolveTarget: resolveMetaMaskMobileLifecycleTarget }
|
|
46
|
+
}) : [];
|
|
41
47
|
const custom = createMetaMaskAdapters(adapter).filter(
|
|
42
|
-
(entry) => declaredActions.has(entry.action) && !existing.has(entry.action)
|
|
48
|
+
(entry) => declaredActions.has(entry.action) && !existing.has(entry.action) && !lifecycle.some((lifecycleEntry) => lifecycleEntry.action === entry.action)
|
|
43
49
|
);
|
|
44
50
|
const autoHudDisabled = process.env.METAMASK_RECIPE_AUTO_HUD === "0" || process.env.METAMASK_RECIPE_AUTO_HUD === "false";
|
|
45
51
|
const logger = options.quietStdout ? new console.Console(process.stderr) : console;
|
|
46
52
|
return createRecipeRunner({
|
|
47
53
|
actionManifest,
|
|
48
|
-
adapters: [...core, ...ui, ...custom],
|
|
54
|
+
adapters: [...core, ...ui, ...lifecycle, ...custom],
|
|
49
55
|
logger,
|
|
50
56
|
recording: {
|
|
51
57
|
targetProvider: createMetaMaskRecordingTargetProvider(adapter)
|
package/docs/CLI-SPEC.md
CHANGED
|
@@ -439,7 +439,7 @@ 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 `
|
|
442
|
+
`--device` is the uniform mobile device selector on `run`, `call`, `doctor`, and `fixtures`. 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
443
|
|
|
444
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).
|
|
445
445
|
|
|
@@ -447,6 +447,7 @@ Readiness check for a checkout without launching the app. Doctor is the single p
|
|
|
447
447
|
|---|---|---|
|
|
448
448
|
| `run` / `call` | resolve id → set serial/simulator env; proceed | **ambiguity gate:** >1 connected mobile device (across both android and ios, counting only targetable ones: android state `device`, iOS state `Booted`) → fail fast (exit 2), listing connected devices and `--device <id>` hints. Exactly one targetable (or zero — existing engine errors speak) → unchanged behavior. |
|
|
449
449
|
| `doctor` | resolve id → set env | no gate — doctor is diagnostic and **reports** the device list (`devices[]`) instead. |
|
|
450
|
+
| `fixtures` | resolve id → set serial/simulator env before sync/set and recovery relaunch | no gate — preserves existing single-device and ambient slot behavior. |
|
|
450
451
|
|
|
451
452
|
`--device` on the **extension/core** adapter is a teaching usage error (those adapters have no device to target). `run --plan` is static (touches no device) and is exempt from the gate.
|
|
452
453
|
|
package/docs/recipe-libraries.md
CHANGED
|
@@ -93,3 +93,206 @@ and a small flow budget. If a flow is team- or task-specific, it belongs in a
|
|
|
93
93
|
team or personal library — that is what the precedence order is for.
|
|
94
94
|
`scripts/check.mjs` validates every committed catalog against the action
|
|
95
95
|
manifests.
|
|
96
|
+
|
|
97
|
+
## Your own measured flow: creating a personal recipe library
|
|
98
|
+
|
|
99
|
+
A **measured flow** is a recipe you run the same way every time to watch how long
|
|
100
|
+
each user-visible step takes. The runner has no benchmark verb: timings are just
|
|
101
|
+
the per-node `duration`s in a passing run's `trace.json`, so a flow is comparable
|
|
102
|
+
across runs only when you pin the run (same device, healing off) and keep the node
|
|
103
|
+
graph stable. The repo ships one canonical example,
|
|
104
|
+
`library/recipes/perps-performance.mobile.recipe.json` — unlock → open the Perps
|
|
105
|
+
market list → read live state → open a market detail. This walkthrough copies it
|
|
106
|
+
into a library of your own and retargets it to your journey. A peer engineer can
|
|
107
|
+
follow it verbatim; the same steps run as the `perps-performance-recipe` contract
|
|
108
|
+
test.
|
|
109
|
+
|
|
110
|
+
### 1. Scaffold a personal library
|
|
111
|
+
|
|
112
|
+
A library is a directory with a `library.json` marker. Keep reusable `flows/`
|
|
113
|
+
(referenced via `call`) beside a `recipes/` folder for the full flows you run:
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
mkdir -p ~/my-recipes/flows ~/my-recipes/recipes
|
|
117
|
+
cat > ~/my-recipes/library.json <<'JSON'
|
|
118
|
+
{ "kind": "recipe-library", "schema_version": 1, "name": "mydev", "owner": "mydev" }
|
|
119
|
+
JSON
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
In real use this is discovered for you: with no `--library` flag and no
|
|
123
|
+
`RECIPE_LIBRARY_PATH`, the runner reads your personal library at
|
|
124
|
+
`$FARMSLOT_HOME/recipe-library` (default `~/.farmslot/recipe-library`). The
|
|
125
|
+
explicit `--library mydev=<dir>` form below is the same mechanism, spelled out so
|
|
126
|
+
it works headlessly (CI, a scratch checkout) and so the path is unambiguous.
|
|
127
|
+
|
|
128
|
+
### 2. Copy the canonical recipe as a starting point
|
|
129
|
+
|
|
130
|
+
Copy it out of your runner checkout's `library/recipes/`:
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
cp library/recipes/perps-performance.mobile.recipe.json \
|
|
134
|
+
~/my-recipes/recipes/my-perps-performance.mobile.recipe.json
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### 3. Edit the nodes to your journey
|
|
138
|
+
|
|
139
|
+
Open the copy and change what you measure while keeping the measured-flow shape.
|
|
140
|
+
Retarget `open-market-detail` to your market and add one extra measured step —
|
|
141
|
+
here, reading live orders on the detail screen:
|
|
142
|
+
|
|
143
|
+
```jsonc
|
|
144
|
+
"open-market-detail": {
|
|
145
|
+
"action": "ui.navigate",
|
|
146
|
+
"page": "perps-market",
|
|
147
|
+
"market": "ETH", // was BTC
|
|
148
|
+
"intent": "Open the ETH Perps market detail screen",
|
|
149
|
+
"next": "read-orders" // was "end"
|
|
150
|
+
},
|
|
151
|
+
"read-orders": { // your extra measured step
|
|
152
|
+
"action": "metamask.perps.read_orders",
|
|
153
|
+
"market": "ETH",
|
|
154
|
+
"intent": "Read live ETH orders on the market detail screen",
|
|
155
|
+
"next": "end"
|
|
156
|
+
}
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
The canonical recipe keeps its nodes inline — a clean measured baseline. For
|
|
160
|
+
bigger journeys you can extract repeated setup steps into a personal `flows/`
|
|
161
|
+
segment and `call` it. For example, define an unlock + open-Perps-list segment
|
|
162
|
+
in `~/my-recipes/flows/mydev.flows.json`:
|
|
163
|
+
|
|
164
|
+
```json
|
|
165
|
+
{
|
|
166
|
+
"schema_version": 1, "kind": "recipe-flow-catalog", "owner": "mydev",
|
|
167
|
+
"flows": {
|
|
168
|
+
"mydev.open_perps_setup": {
|
|
169
|
+
"version": 1,
|
|
170
|
+
"description": "Unlock the wallet and open the Perps market list.",
|
|
171
|
+
"workflow": {
|
|
172
|
+
"entry": "ensure-unlocked",
|
|
173
|
+
"nodes": {
|
|
174
|
+
"ensure-unlocked": {
|
|
175
|
+
"action": "metamask.wallet.ensure_unlocked",
|
|
176
|
+
"intent": "Unlock the wallet before the Perps journey",
|
|
177
|
+
"next": "open-perps-list"
|
|
178
|
+
},
|
|
179
|
+
"open-perps-list": {
|
|
180
|
+
"action": "ui.navigate",
|
|
181
|
+
"page": "perps",
|
|
182
|
+
"intent": "Open the Perps market list screen",
|
|
183
|
+
"next": "done"
|
|
184
|
+
},
|
|
185
|
+
"done": { "action": "end", "status": "pass" }
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Then replace the two inline setup nodes in your recipe with a single `call` node:
|
|
194
|
+
|
|
195
|
+
```jsonc
|
|
196
|
+
"setup": {
|
|
197
|
+
"action": "call",
|
|
198
|
+
"ref": "mydev.open_perps_setup",
|
|
199
|
+
"intent": "Run the personal Perps setup segment (unlock + open list)",
|
|
200
|
+
"next": "read-positions"
|
|
201
|
+
}
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
The segment validates via `--plan` — the plan step verifies the `call` ref
|
|
205
|
+
resolves from the library. Keep flow segment actions within the mobile action
|
|
206
|
+
surface: the flow catalog format has no `platform`/`adapter` dimension today,
|
|
207
|
+
so a flow using a core-only action (e.g. `command`) will pass `--plan` on mobile
|
|
208
|
+
but fail at live-run time with "No adapter registered for flow action X". There
|
|
209
|
+
is no plan-time cross-adapter enforcement; that gap would require an `adapters`
|
|
210
|
+
field on flow catalog entries — not yet in the protocol.
|
|
211
|
+
|
|
212
|
+
### 4. Run it and read the timings
|
|
213
|
+
|
|
214
|
+
Validate statically **by name** first — `run` probes each library source's `recipes/`
|
|
215
|
+
directory in precedence order (personal → team → canonical), so `my-perps-performance`
|
|
216
|
+
resolves from `~/my-recipes/recipes/` without you spelling out the path:
|
|
217
|
+
|
|
218
|
+
```bash
|
|
219
|
+
# Static validation by NAME — resolves from the personal library via --library.
|
|
220
|
+
mm-harness run my-perps-performance \
|
|
221
|
+
--library mydev=~/my-recipes --plan --adapter mobile
|
|
222
|
+
|
|
223
|
+
# Zero-flag personal-library: when ~/my-recipes is placed at
|
|
224
|
+
# $FARMSLOT_HOME/recipe-library (default ~/.farmslot/recipe-library), the runner
|
|
225
|
+
# discovers it automatically and run-by-name works without --library:
|
|
226
|
+
mm-harness run my-perps-performance --plan --adapter mobile
|
|
227
|
+
|
|
228
|
+
# Pinned live run — same device, healing OFF, so durations are comparable.
|
|
229
|
+
mm-harness run my-perps-performance \
|
|
230
|
+
--library mydev=~/my-recipes \
|
|
231
|
+
--adapter mobile --device <serial> --heal off --artifacts-dir artifacts
|
|
232
|
+
|
|
233
|
+
# Canonical start-state variants — same installed app, no rebuild.
|
|
234
|
+
mm-harness run app-lifecycle-android-smoke \
|
|
235
|
+
--adapter mobile --device <serial> --heal off --artifacts-dir artifacts/lifecycle-smoke
|
|
236
|
+
mm-harness run perps-performance-warm-start \
|
|
237
|
+
--adapter mobile --device <serial> --heal off --artifacts-dir artifacts/warm
|
|
238
|
+
mm-harness run perps-performance-background-resume \
|
|
239
|
+
--adapter mobile --device <serial> --heal off --artifacts-dir artifacts/background
|
|
240
|
+
mm-harness run perps-performance-cold-start \
|
|
241
|
+
--adapter mobile --device <serial> --heal off --artifacts-dir artifacts/cold
|
|
242
|
+
|
|
243
|
+
# Per-node durations to diff across runs (trace.json is an array of entries, or
|
|
244
|
+
# { metadata, entries: [...] }; each entry carries nodeId + durationMs):
|
|
245
|
+
node -e 'const t=require("./artifacts/trace.json"); \
|
|
246
|
+
for (const e of Array.isArray(t)?t:t.entries) console.log(e.nodeId, e.durationMs)'
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
A miss with `--library` names the sources that were searched, so you can tell at a
|
|
250
|
+
glance whether a typo or a missing library entry caused the failure.
|
|
251
|
+
|
|
252
|
+
Run `app-lifecycle-android-smoke` first when validating a new Android slot; it
|
|
253
|
+
isolates lifecycle control from wallet setup and Perps navigation. The start-state
|
|
254
|
+
variants use the standard outer `app.lifecycle` action. Every
|
|
255
|
+
variant begins with `app.status` so `trace.json` records an idempotent start
|
|
256
|
+
marker before lifecycle setup and Perps timing nodes. Android background resume
|
|
257
|
+
sends HOME, then relaunches through the Expo dev-client deep link. Cold start
|
|
258
|
+
force-stops the installed package, then launches the same build through the deep
|
|
259
|
+
link.
|
|
260
|
+
|
|
261
|
+
### The measured-flow pattern
|
|
262
|
+
|
|
263
|
+
Five rules keep timings meaningful and diffable:
|
|
264
|
+
|
|
265
|
+
1. **Pin the run** — `--device <serial> --heal off`. Healing retries hide the
|
|
266
|
+
regressions you are trying to measure.
|
|
267
|
+
2. **One node per user-visible step** — a node's `duration` is only a signal when
|
|
268
|
+
it maps to a single thing the user sees.
|
|
269
|
+
3. **Stable, human-meaningful node keys** — operators diff node keys across runs;
|
|
270
|
+
renaming `open-market-detail` breaks every historical comparison.
|
|
271
|
+
4. **No destructive side effects** — a measured flow should be repeatable. The
|
|
272
|
+
canonical recipe stops at read + navigate; add `place_order`/`close` only in a
|
|
273
|
+
personal copy when you deliberately want to measure the trade path.
|
|
274
|
+
5. **Read timings from `trace.json`, not the console** — the trace is the durable
|
|
275
|
+
per-node record; the console is for humans watching the run.
|
|
276
|
+
|
|
277
|
+
### Shadowing, in practice
|
|
278
|
+
|
|
279
|
+
Because `--library` sources resolve `call` refs before the canonical `metamask`
|
|
280
|
+
library, a personal flow named like a canonical one shadows it — your history is
|
|
281
|
+
the point. You can watch this happen on mobile with the segment recipe from the
|
|
282
|
+
walkthrough above:
|
|
283
|
+
|
|
284
|
+
```bash
|
|
285
|
+
# A mobile recipe with "action": "call", "ref": "mydev.open_perps_setup"
|
|
286
|
+
# fails without the library source…
|
|
287
|
+
mm-harness run my-perps-with-segment --plan --adapter mobile
|
|
288
|
+
# → workflow.unresolved_call_ref
|
|
289
|
+
|
|
290
|
+
# …and resolves once the personal library is on the path:
|
|
291
|
+
mm-harness run my-perps-with-segment --plan --adapter mobile \
|
|
292
|
+
--library "mydev=~/my-recipes"
|
|
293
|
+
# → plan pass
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
That resolution — unresolved without the source, `pass` with it — is what the
|
|
297
|
+
`perps-performance-recipe` contract test asserts, alongside the canonical recipe
|
|
298
|
+
resolving by name and the personal mobile copy planning by path and basename.
|
|
@@ -71,10 +71,14 @@ export async function bridgeEnv(input) {
|
|
|
71
71
|
const watcherPort = target.watcherPort;
|
|
72
72
|
const simulator = target.iosSimulator;
|
|
73
73
|
const androidDevice = target.androidDevice;
|
|
74
|
+
const androidTargetDeviceName = target.androidTargetDeviceName;
|
|
74
75
|
const adbSerial = target.adbSerial;
|
|
75
76
|
if (watcherPort !== undefined && watcherPort !== null && String(watcherPort) !== '') env.WATCHER_PORT = String(watcherPort);
|
|
76
77
|
if (simulator !== undefined && simulator !== null && String(simulator) !== '') env.IOS_SIMULATOR = String(simulator);
|
|
77
78
|
if (androidDevice !== undefined && androidDevice !== null && String(androidDevice) !== '') env.ANDROID_DEVICE = String(androidDevice);
|
|
79
|
+
if (androidTargetDeviceName !== undefined && androidTargetDeviceName !== null && String(androidTargetDeviceName) !== '') {
|
|
80
|
+
env.ANDROID_TARGET_DEVICE_NAME = String(androidTargetDeviceName);
|
|
81
|
+
}
|
|
78
82
|
if (adbSerial !== undefined && adbSerial !== null && String(adbSerial) !== '') {
|
|
79
83
|
env.ADB_SERIAL = String(adbSerial);
|
|
80
84
|
env.ANDROID_SERIAL = String(adbSerial);
|
|
@@ -93,6 +97,20 @@ export async function bridgeEnv(input) {
|
|
|
93
97
|
env.ANDROID_TARGET_DEVICE_NAME = model;
|
|
94
98
|
}
|
|
95
99
|
}
|
|
100
|
+
if (serialStr) {
|
|
101
|
+
// ANDROID_DEVICE === ADB_SERIAL is the explicit --device android pin shape
|
|
102
|
+
// (device-target.ts / launch --device). Slots hosting BOTH platforms carry an
|
|
103
|
+
// ambient IOS_SIMULATOR in their context env, which spreads over process.env
|
|
104
|
+
// above and would win target discovery's simulator filter — sending a pinned
|
|
105
|
+
// android action to the iOS target. An explicit android pin suppresses the
|
|
106
|
+
// ambient simulator identity.
|
|
107
|
+
// LOAD-BEARING: device-target.ts also clears IOS_SIMULATOR, but
|
|
108
|
+
// resolveSlotPorts re-injects the slot context over process.env afterwards —
|
|
109
|
+
// this re-clear (running last, on the child env actually handed to the
|
|
110
|
+
// bridge) is what actually protects the pin. Keep it even if the CLI-level
|
|
111
|
+
// clear looks redundant.
|
|
112
|
+
env.IOS_SIMULATOR = '';
|
|
113
|
+
}
|
|
96
114
|
return env;
|
|
97
115
|
}
|
|
98
116
|
|
|
@@ -101,8 +119,74 @@ function resolveMobileTarget(input) {
|
|
|
101
119
|
const watcherPort = input.node?.watcher_port ?? input.node?.metro_port ?? input.node?.cdp_port ?? contextEnv.WATCHER_PORT ?? contextEnv.CDP_PORT ?? contextEnv.RECIPE_CDP_PORT ?? process.env.WATCHER_PORT ?? process.env.CDP_PORT ?? process.env.RECIPE_CDP_PORT;
|
|
102
120
|
const iosSimulator = input.node?.simulator ?? input.node?.ios_simulator ?? contextEnv.IOS_SIMULATOR ?? process.env.IOS_SIMULATOR;
|
|
103
121
|
const androidDevice = input.node?.android_device ?? contextEnv.ANDROID_DEVICE ?? process.env.ANDROID_DEVICE;
|
|
122
|
+
const androidTargetDeviceName = input.node?.android_target_device_name ?? input.node?.androidTargetDeviceName ?? contextEnv.ANDROID_TARGET_DEVICE_NAME ?? process.env.ANDROID_TARGET_DEVICE_NAME;
|
|
104
123
|
const adbSerial = input.node?.adb_serial ?? contextEnv.ADB_SERIAL ?? contextEnv.ANDROID_SERIAL ?? process.env.ADB_SERIAL ?? process.env.ANDROID_SERIAL ?? androidDevice;
|
|
105
|
-
return { watcherPort, iosSimulator, androidDevice, adbSerial };
|
|
124
|
+
return { watcherPort, iosSimulator, androidDevice, androidTargetDeviceName, adbSerial };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function androidDeviceNameMatches(deviceName, targetName) {
|
|
128
|
+
if (!targetName) return false;
|
|
129
|
+
return deviceName === targetName || deviceName.startsWith(`${targetName} -`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Select THIS action's entry from a multi-target bridge `status` reply (the bridge
|
|
133
|
+
// probes every RN target on Metro, so dual-device slots return an array). Single
|
|
134
|
+
// source of truth for wallet actions — the previous per-action copies checked the
|
|
135
|
+
// ambient IOS_SIMULATOR before the android pin and only ever exact-matched serials
|
|
136
|
+
// against Metro deviceNames (which are model descriptors, never serials), so a
|
|
137
|
+
// pinned android run selected the iOS entry on dual-platform slots.
|
|
138
|
+
export function selectBridgeStatusEntry(status, input) {
|
|
139
|
+
const entries = Array.isArray(status)
|
|
140
|
+
? status.filter((entry) => entry && typeof entry === 'object')
|
|
141
|
+
: (status && typeof status === 'object' ? [status] : []);
|
|
142
|
+
if (entries.length === 0) return null;
|
|
143
|
+
const target = resolveMobileTarget(input);
|
|
144
|
+
const adbSerial = target.adbSerial != null ? String(target.adbSerial) : '';
|
|
145
|
+
const androidDevice = target.androidDevice != null ? String(target.androidDevice) : '';
|
|
146
|
+
const androidTargetDeviceName = target.androidTargetDeviceName != null ? String(target.androidTargetDeviceName) : '';
|
|
147
|
+
const iosSimulator = target.iosSimulator != null ? String(target.iosSimulator) : '';
|
|
148
|
+
|
|
149
|
+
// Explicit android pin shape (--device <serial> sets ANDROID_DEVICE === ADB_SERIAL).
|
|
150
|
+
// Checked BEFORE the simulator identity: dual-platform slots inject an ambient
|
|
151
|
+
// IOS_SIMULATOR that must not capture a pinned android action.
|
|
152
|
+
if (adbSerial && (androidTargetDeviceName || androidDevice === adbSerial || !androidDevice)) {
|
|
153
|
+
const android = entries.filter((entry) => entry.platform === 'android');
|
|
154
|
+
if (androidTargetDeviceName) {
|
|
155
|
+
const matchesAndroidTargetNameEntry = (entry) => {
|
|
156
|
+
const deviceName = String(entry.deviceName ?? '');
|
|
157
|
+
return androidDeviceNameMatches(deviceName, androidTargetDeviceName);
|
|
158
|
+
};
|
|
159
|
+
const byName = android.filter(matchesAndroidTargetNameEntry);
|
|
160
|
+
if (byName.length === 1) return byName[0];
|
|
161
|
+
const byModelName = entries.filter(matchesAndroidTargetNameEntry);
|
|
162
|
+
if (byModelName.length === 1) return byModelName[0];
|
|
163
|
+
}
|
|
164
|
+
if (android.length === 1) return android[0];
|
|
165
|
+
if (android.length > 1) {
|
|
166
|
+
// Two android targets can't be told apart by serial here (Metro deviceNames
|
|
167
|
+
// are model descriptors); prefer the usable one.
|
|
168
|
+
return android.find((entry) => entry.account) ?? android[0];
|
|
169
|
+
}
|
|
170
|
+
// Pinned android but no android entry answered — failing fast beats letting the
|
|
171
|
+
// caller read the iOS entry and report misleading wallet state.
|
|
172
|
+
throw new Error(
|
|
173
|
+
`Pinned android device ${adbSerial} has no responding bridge target.\n` +
|
|
174
|
+
` Bridge status entries:\n` +
|
|
175
|
+
entries.map((entry) => ` - ${entry.deviceName ?? '?'} [${entry.platform || 'unknown'}]`).join('\n') +
|
|
176
|
+
`\n Next: mm-harness launch android # bring the app back to the foreground`,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
if (iosSimulator) {
|
|
180
|
+
const sim = entries.find((entry) => entry.deviceName === iosSimulator);
|
|
181
|
+
if (sim) return sim;
|
|
182
|
+
}
|
|
183
|
+
if (androidDevice) {
|
|
184
|
+
const byName = entries.find(
|
|
185
|
+
(entry) => androidDeviceNameMatches(String(entry.deviceName ?? ''), androidDevice),
|
|
186
|
+
);
|
|
187
|
+
if (byName) return byName;
|
|
188
|
+
}
|
|
189
|
+
return entries.find((entry) => entry.account) ?? entries[0];
|
|
106
190
|
}
|
|
107
191
|
|
|
108
192
|
// Commands where a transient undefined/empty stdout means "not yet settled", not failure.
|
|
@@ -129,9 +213,12 @@ export async function bridgeCommand(input, args) {
|
|
|
129
213
|
if (settled) return;
|
|
130
214
|
settled = true;
|
|
131
215
|
child.kill('SIGTERM');
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
216
|
+
// Always escalate after the grace period: child.killed only records that
|
|
217
|
+
// kill() was CALLED, not that the process exited, so gating SIGKILL on it
|
|
218
|
+
// never fires and a SIGTERM-ignoring child leaks. The close listener
|
|
219
|
+
// cancels the escalation when the child exits in time.
|
|
220
|
+
const killTimer = setTimeout(() => child.kill('SIGKILL'), 1000);
|
|
221
|
+
child.once('close', () => clearTimeout(killTimer));
|
|
135
222
|
resolve({ exitCode: null, stdout, stderr, timedOut: true, timeoutMs });
|
|
136
223
|
}, timeoutMs)
|
|
137
224
|
: null;
|