@deeeed/metamask-harness 0.19.1 → 0.20.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 +18 -0
- package/adapters/extension/ensure-browser.sh +6 -4
- package/adapters/extension/launch-browser.cjs +6 -1
- package/adapters/extension/lib/chrome-args.cjs +11 -1
- package/adapters/extension/start-watch.sh +1 -1
- package/adapters/extension/verify.sh +17 -0
- package/adapters/mobile/open-device.sh +12 -1
- package/adapters/mobile/wait-for-bridge.sh +1 -1
- package/dist/adapters/extension/ensure-ready.js +7 -2
- package/dist/adapters/extension/runtime-decision.js +4 -6
- package/dist/adapters/extension/runtime.js +114 -17
- package/dist/adapters/mobile/prepare.js +40 -6
- package/dist/command-contract.js +58 -5
- package/dist/commands/call.js +6 -3
- package/dist/commands/check.js +9 -2
- package/dist/commands/checklist.js +117 -14
- package/dist/commands/launch/extension.js +8 -3
- package/dist/commands/launch/index.js +38 -25
- package/dist/commands/launch/mobile.js +2 -2
- package/dist/commands/manifest.js +72 -5
- package/dist/commands/run-engine.js +7 -2
- package/dist/heal-bounds.js +1 -1
- package/dist/metamask-action-validation.js +45 -0
- package/dist/mm-harness-cli.js +7 -4
- package/docs/CONTRIBUTING.md +8 -0
- package/library/actions/extension/platform/cdp.mjs +8 -3
- package/library/actions/extension/ui/navigate.mjs +239 -16
- package/library/actions/mobile/ui/navigate.mjs +1 -1
- package/library/manifests/core.action-manifest.json +60 -7
- package/library/manifests/extension.action-manifest.json +55 -13
- package/library/manifests/mobile.action-manifest.json +52 -12
- package/library/recipes/perps/lifecycle.recipe.json +3 -9
- package/library/recipes/runner/action-validation.extension.recipe.json +5 -4
- package/package.json +5 -4
- package/scripts/completions.sh +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.20.0 - 2026-07-24
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- `mm-harness checklist closeout` delegates learning capture to `@farmslot/handoff`; successful terminal marks stage automatically while sharing stays explicit.
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- Extension readiness bounds both CDP connection setup and page calls instead of hanging.
|
|
14
|
+
- Extension readiness reports a suspended UI compositor instead of claiming a reachable CDP page is interactive.
|
|
15
|
+
- Extension hash navigation stays inside the running app so SPA routes are not reset during startup.
|
|
16
|
+
- `ui.navigate page=perps` discovers and activates current Perps controls, including bounded back navigation from detail screens, and verifies the destination; bundled recipes no longer embed stale product routes.
|
|
17
|
+
- Semantic Extension navigation honors the caller timeout while avoiding repeated control activation.
|
|
18
|
+
- Invalid enum recovery commands use the closest valid value instead of repeating the rejected input.
|
|
19
|
+
- Perps mutation plans fail early when required market, side, or amount inputs are missing.
|
|
20
|
+
- `check diff` honors the checkout's configured ESLint config file.
|
|
21
|
+
- Extension builds no longer certify dependency state; Yarn `node-modules` freshness follows the installed tree and only successful installs record its baseline.
|
|
22
|
+
|
|
5
23
|
## 0.19.1 - 2026-07-23
|
|
6
24
|
|
|
7
25
|
### Added
|
|
@@ -277,7 +277,11 @@ const fs = require('fs');
|
|
|
277
277
|
const http = require('http');
|
|
278
278
|
const { execSync, execFileSync } = require('child_process');
|
|
279
279
|
let chromium; try { chromium = require('@playwright/test').chromium; } catch { chromium = require('playwright').chromium; }
|
|
280
|
-
const {
|
|
280
|
+
const {
|
|
281
|
+
automationRuntimeArgs,
|
|
282
|
+
isolatedProfileArgs,
|
|
283
|
+
remoteDebuggingArgs,
|
|
284
|
+
} = require(path.join('${SCRIPT_DIR}', 'lib/chrome-args.cjs'));
|
|
281
285
|
const { captureMacFrontmostProcess, restoreMacFrontmostProcess } = require(path.join('${SCRIPT_DIR}', 'lib/macos-focus.cjs'));
|
|
282
286
|
|
|
283
287
|
const SLOT_ID = '${SLOT_ID}';
|
|
@@ -304,9 +308,7 @@ const resumeWebpack = () => {
|
|
|
304
308
|
'--user-data-dir=' + PROFILE,
|
|
305
309
|
'--disable-extensions-except=' + EXTENSION,
|
|
306
310
|
'--load-extension=' + EXTENSION,
|
|
307
|
-
|
|
308
|
-
'--disable-backgrounding-occluded-windows',
|
|
309
|
-
'--disable-renderer-backgrounding',
|
|
311
|
+
...automationRuntimeArgs(),
|
|
310
312
|
'--no-first-run',
|
|
311
313
|
'--no-default-browser-check',
|
|
312
314
|
...isolatedProfileArgs(),
|
|
@@ -24,7 +24,11 @@ const os = require('node:os');
|
|
|
24
24
|
const path = require('node:path');
|
|
25
25
|
const { execFileSync, spawn, spawnSync } = require('node:child_process');
|
|
26
26
|
const { extensionIdFromExtensionDir } = require('./lib/extension-id.cjs');
|
|
27
|
-
const {
|
|
27
|
+
const {
|
|
28
|
+
automationRuntimeArgs,
|
|
29
|
+
isolatedProfileArgs,
|
|
30
|
+
remoteDebuggingArgs,
|
|
31
|
+
} = require('./lib/chrome-args.cjs');
|
|
28
32
|
const { captureMacFrontmostProcess, restoreMacFrontmostProcess } = require('./lib/macos-focus.cjs');
|
|
29
33
|
|
|
30
34
|
if (process.argv.includes('--help') || process.argv.includes('-h')) {
|
|
@@ -99,6 +103,7 @@ const chromeArgs = [
|
|
|
99
103
|
...remoteDebuggingArgs(cdpPort),
|
|
100
104
|
'--no-first-run',
|
|
101
105
|
'--disable-first-run-ui',
|
|
106
|
+
...automationRuntimeArgs(),
|
|
102
107
|
...isolatedProfileArgs(),
|
|
103
108
|
'--disable-default-apps',
|
|
104
109
|
'--disable-popup-blocking',
|
|
@@ -28,4 +28,14 @@ function isolatedProfileArgs(platform = process.platform) {
|
|
|
28
28
|
return platform === 'darwin' ? ['--use-mock-keychain'] : [];
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
// Keep requestAnimationFrame, transitions, and input hit testing alive when the
|
|
32
|
+
// slot-owned browser is occluded by the operator's other windows.
|
|
33
|
+
function automationRuntimeArgs() {
|
|
34
|
+
return [
|
|
35
|
+
'--disable-background-timer-throttling',
|
|
36
|
+
'--disable-backgrounding-occluded-windows',
|
|
37
|
+
'--disable-renderer-backgrounding',
|
|
38
|
+
];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
module.exports = { automationRuntimeArgs, isolatedProfileArgs, remoteDebuggingArgs };
|
|
@@ -338,6 +338,6 @@ fi
|
|
|
338
338
|
echo '[recipe-harness] yarn start compiled successfully'
|
|
339
339
|
if [ -n "$RUNNER_BIN" ]; then
|
|
340
340
|
"$RUNNER_BIN" runtime-decision --adapter extension --target . --record-baseline --json >/dev/null 2>&1 || true
|
|
341
|
-
echo '[recipe-harness] recorded
|
|
341
|
+
echo '[recipe-harness] recorded webpack cache baseline (runtime-decision --record-baseline)'
|
|
342
342
|
fi
|
|
343
343
|
status=pass
|
|
@@ -407,6 +407,23 @@ if [ "$STATIC_ONLY" = false ]; then
|
|
|
407
407
|
refresh_extension_id
|
|
408
408
|
else
|
|
409
409
|
checks+=("{\"name\":\"home-tab convergence\",\"status\":\"fail\",\"detail\":\"see logs/extension-ensure-ready.json\"}")
|
|
410
|
+
node - "$ARTIFACTS/logs/extension-ensure-ready.json" <<'NODE' >&2
|
|
411
|
+
const fs = require('fs');
|
|
412
|
+
const file = process.argv[2];
|
|
413
|
+
try {
|
|
414
|
+
const report = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
415
|
+
const code = report.health?.errorCode;
|
|
416
|
+
const findings = Array.isArray(report.health?.findings) ? report.health.findings : [];
|
|
417
|
+
if (code) {
|
|
418
|
+
const finding = findings[0] ?? 'Extension UI is not interactive.';
|
|
419
|
+
console.error(finding.startsWith(`${code}:`) ? finding : `${code}: ${finding}`);
|
|
420
|
+
}
|
|
421
|
+
else if (findings[0]) console.error(findings[0]);
|
|
422
|
+
if (report.health?.userAction) console.error(`Next: ${report.health.userAction}`);
|
|
423
|
+
} catch {
|
|
424
|
+
// The summary retains the raw helper output for diagnosis.
|
|
425
|
+
}
|
|
426
|
+
NODE
|
|
410
427
|
status="fail"
|
|
411
428
|
fi
|
|
412
429
|
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
# --simulator <udid|name|"booted"> iOS simulator (default: IOS_SIMULATOR env, else "booted")
|
|
14
14
|
# --adb-serial <serial> Android device serial (default: ADB_SERIAL env)
|
|
15
15
|
# --preflight-mode fast|auto|rebuild-native|clean (default: fast; env MOBILE_PREFLIGHT_MODE)
|
|
16
|
+
# --restart terminate the selected app before opening it
|
|
16
17
|
# --ui-only iOS only: ensure Simulator.app is visible; do not relaunch the app
|
|
17
18
|
# Outputs:
|
|
18
19
|
# Progress on stderr; exit 0 pass; 1 open failed; 2 bad args.
|
|
@@ -27,6 +28,7 @@ ADB_SERIAL_ARG="${ADB_SERIAL:-${ANDROID_SERIAL:-}}"
|
|
|
27
28
|
PREFLIGHT_MODE="${MOBILE_PREFLIGHT_MODE:-fast}"
|
|
28
29
|
PORT="${WATCHER_PORT:-${METRO_PORT:-8081}}"
|
|
29
30
|
UI_ONLY=false
|
|
31
|
+
RESTART=false
|
|
30
32
|
|
|
31
33
|
while [ "$#" -gt 0 ]; do
|
|
32
34
|
case "$1" in
|
|
@@ -36,11 +38,12 @@ while [ "$#" -gt 0 ]; do
|
|
|
36
38
|
--adb-serial) ADB_SERIAL_ARG="$2"; shift 2 ;;
|
|
37
39
|
--preflight-mode) PREFLIGHT_MODE="$2"; shift 2 ;;
|
|
38
40
|
--port) PORT="$2"; shift 2 ;;
|
|
41
|
+
--restart) RESTART=true; shift ;;
|
|
39
42
|
--ui-only) UI_ONLY=true; shift ;;
|
|
40
43
|
-h|--help)
|
|
41
44
|
printf 'Usage: open-device.sh [--platform ios|android] [--target <dir>]\n'
|
|
42
45
|
printf ' [--simulator <udid|name|booted>] [--adb-serial <serial>]\n'
|
|
43
|
-
printf ' [--preflight-mode fast|auto|rebuild-native|clean] [--port <n>] [--ui-only]\n'
|
|
46
|
+
printf ' [--preflight-mode fast|auto|rebuild-native|clean] [--port <n>] [--restart] [--ui-only]\n'
|
|
44
47
|
exit 0
|
|
45
48
|
;;
|
|
46
49
|
*) printf 'open-device: unknown arg: %s\n' "$1" >&2; exit 2 ;;
|
|
@@ -265,6 +268,10 @@ if [ "$PLATFORM" = "ios" ]; then
|
|
|
265
268
|
LAUNCHED=false
|
|
266
269
|
for BUNDLE_ID in "${BUNDLE_IDS[@]}"; do
|
|
267
270
|
ensure_ios_app_for_mode "$SIM_TARGET" "$BUNDLE_ID" "$PREFLIGHT_MODE" || continue
|
|
271
|
+
if $RESTART; then
|
|
272
|
+
xcrun simctl terminate "$SIM_TARGET" "$BUNDLE_ID" >/dev/null 2>&1 || true
|
|
273
|
+
printf 'Restarting iOS dev client %s on %s\n' "$BUNDLE_ID" "$SIM_TARGET" >&2
|
|
274
|
+
fi
|
|
268
275
|
|
|
269
276
|
# Dismiss Expo dev-menu onboarding for clean headless runs.
|
|
270
277
|
xcrun simctl spawn "$SIM_TARGET" defaults write "$BUNDLE_ID" \
|
|
@@ -314,6 +321,10 @@ else
|
|
|
314
321
|
LAUNCHED=false
|
|
315
322
|
for PKG in "${ANDROID_PKGS[@]}"; do
|
|
316
323
|
ensure_android_app_for_mode "$PKG" "$PREFLIGHT_MODE" "${ADB_ARGS[@]}" || continue
|
|
324
|
+
if $RESTART; then
|
|
325
|
+
adb "${ADB_ARGS[@]}" shell am force-stop "$PKG" >/dev/null 2>&1 || true
|
|
326
|
+
printf 'Restarting Android dev client %s\n' "$PKG" >&2
|
|
327
|
+
fi
|
|
317
328
|
|
|
318
329
|
ENCODED_URL="$(urlencode "http://localhost:${PORT}?disableOnboarding=1" 2>/dev/null \
|
|
319
330
|
|| printf 'http%%3A%%2F%%2Flocalhost%%3A%s%%3FdisableOnboarding%%3D1' "$PORT")"
|
|
@@ -158,5 +158,5 @@ printf 'wait-for-bridge: no bridge target matched the requested %s on port %s (%
|
|
|
158
158
|
"$(requested_target)" "$PORT" "$MAX_POLLS" "$((MAX_POLLS * 2))" >&2
|
|
159
159
|
printf ' requested: %s\n' "$(requested_target)" >&2
|
|
160
160
|
printf ' answered: %s\n' "$(answered_targets)" >&2
|
|
161
|
-
printf ' Next: check %s — or run mm-harness
|
|
161
|
+
printf ' Next: check %s — or run mm-harness status --json\n' "$STATUS_LOG" >&2
|
|
162
162
|
exit 1
|
|
@@ -135,7 +135,12 @@ async function ensureExtensionReady(target, options) {
|
|
|
135
135
|
try {
|
|
136
136
|
const { checkExtensionRuntimeHealth } = await import("./runtime.js");
|
|
137
137
|
const report = await checkExtensionRuntimeHealth(resolved, cdpPort);
|
|
138
|
-
return {
|
|
138
|
+
return {
|
|
139
|
+
status: report.status,
|
|
140
|
+
...report.errorCode ? { errorCode: report.errorCode } : {},
|
|
141
|
+
...report.userAction ? { userAction: report.userAction } : {},
|
|
142
|
+
findings: report.findings
|
|
143
|
+
};
|
|
139
144
|
} catch (error) {
|
|
140
145
|
return { status: "FAIL", findings: [error instanceof Error ? error.message : String(error)] };
|
|
141
146
|
}
|
|
@@ -180,7 +185,7 @@ async function ensureExtensionReady(target, options) {
|
|
|
180
185
|
homeTabs: { before, closed, after },
|
|
181
186
|
...slotTitle ? { slotTitle } : {},
|
|
182
187
|
ready,
|
|
183
|
-
reasonCode: ready ? "ready" : after !== 1 ? "tab-count" : "unhealthy",
|
|
188
|
+
reasonCode: ready ? "ready" : after !== 1 ? "tab-count" : health.errorCode ?? "unhealthy",
|
|
184
189
|
health
|
|
185
190
|
});
|
|
186
191
|
}
|
|
@@ -4,8 +4,7 @@ import fs from "node:fs";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import {
|
|
6
6
|
depsCheck,
|
|
7
|
-
INSTALL_MARKERS
|
|
8
|
-
recordDepsBaseline
|
|
7
|
+
INSTALL_MARKERS
|
|
9
8
|
} from "@farmslot/recipe-harness/runtime/deps-readiness";
|
|
10
9
|
import { recipeHarnessPath, recipeRuntimeDir, recipeWatchLogCandidates } from "../../paths.js";
|
|
11
10
|
const WEBPACK_DIRECT_INPUTS = ["package.json", "yarn.lock", ".yarnrc.yml", ".tool-versions"];
|
|
@@ -236,14 +235,13 @@ async function cdpCheck(target, cdpPort, pageMode) {
|
|
|
236
235
|
return { status: "fail", findings: [error instanceof Error ? error.message : String(error)] };
|
|
237
236
|
}
|
|
238
237
|
}
|
|
239
|
-
function
|
|
240
|
-
recordDepsBaseline(target);
|
|
238
|
+
function recordWebpackBaseline(target) {
|
|
241
239
|
const cache = webpackFingerprint(target);
|
|
242
240
|
writeBaseline(target, "webpack-cache-state.json", cache);
|
|
243
241
|
}
|
|
244
242
|
async function decideExtensionReadiness(target, options = {}) {
|
|
245
243
|
const resolved = path.resolve(target);
|
|
246
|
-
if (options.record)
|
|
244
|
+
if (options.record) recordWebpackBaseline(resolved);
|
|
247
245
|
const deps = depsCheck(resolved);
|
|
248
246
|
const webpackCache = webpackCacheCheck(resolved);
|
|
249
247
|
const buildLog = buildLogCheck(resolved, options.watchLog);
|
|
@@ -355,6 +353,6 @@ function buildAction(target, clean) {
|
|
|
355
353
|
export {
|
|
356
354
|
decideExtensionReadiness,
|
|
357
355
|
isExtensionDistStale,
|
|
358
|
-
|
|
356
|
+
recordWebpackBaseline,
|
|
359
357
|
runtimeDistCheck
|
|
360
358
|
};
|
|
@@ -2,7 +2,12 @@ import { spawn } from "node:child_process";
|
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { importRecipeHarnessRuntimeBrowserExtension, importRecipeHarnessRuntimeCdp, recipeRuntimeDir, resolveLocalProtocolRoot, resolveRequiredLocalProtocolRoot, runnerDir } from "../../paths.js";
|
|
5
|
-
const {
|
|
5
|
+
const {
|
|
6
|
+
CdpSession,
|
|
7
|
+
jsonGet,
|
|
8
|
+
probeCdpCompositorInteractivity,
|
|
9
|
+
sleep
|
|
10
|
+
} = await importRecipeHarnessRuntimeCdp();
|
|
6
11
|
const { extensionIdFromTarget } = await importRecipeHarnessRuntimeBrowserExtension();
|
|
7
12
|
async function prepareExtensionRuntime(options) {
|
|
8
13
|
const projectRoot = path.resolve(options.projectRoot);
|
|
@@ -39,6 +44,9 @@ async function assertHealthyExtensionRuntime(options) {
|
|
|
39
44
|
pageMode: options.pageMode
|
|
40
45
|
});
|
|
41
46
|
if (lastReport.status === "PASS") return lastReport;
|
|
47
|
+
if (lastReport.errorCode === "UI_COMPOSITOR_SUSPENDED") {
|
|
48
|
+
throw new Error(formatHealthFailure(lastReport, options.projectRoot));
|
|
49
|
+
}
|
|
42
50
|
await sleep(500);
|
|
43
51
|
}
|
|
44
52
|
const report = lastReport ?? failReport(options.cdpPort, ["CDP runtime was not probed."], {});
|
|
@@ -48,9 +56,14 @@ async function checkExtensionRuntimeHealth(projectRoot, cdpPort, options = {}) {
|
|
|
48
56
|
const findings = [];
|
|
49
57
|
const warnings = [];
|
|
50
58
|
let targets = [];
|
|
59
|
+
const cdpCallTimeoutMs = options.cdpCallTimeoutMs ?? 5e3;
|
|
51
60
|
try {
|
|
52
|
-
await jsonGet(`http://127.0.0.1:${cdpPort}/json/version
|
|
53
|
-
|
|
61
|
+
await jsonGet(`http://127.0.0.1:${cdpPort}/json/version`, {
|
|
62
|
+
timeoutMs: cdpCallTimeoutMs
|
|
63
|
+
});
|
|
64
|
+
const rawTargets = await jsonGet(`http://127.0.0.1:${cdpPort}/json/list`, {
|
|
65
|
+
timeoutMs: cdpCallTimeoutMs
|
|
66
|
+
});
|
|
54
67
|
targets = Array.isArray(rawTargets) ? rawTargets : [];
|
|
55
68
|
} catch (error) {
|
|
56
69
|
return failReport(cdpPort, [`CDP is not reachable on port ${cdpPort}: ${messageOf(error)}`], {});
|
|
@@ -76,11 +89,57 @@ async function checkExtensionRuntimeHealth(projectRoot, cdpPort, options = {}) {
|
|
|
76
89
|
if (String(target.url ?? "").startsWith("chrome-error://")) {
|
|
77
90
|
findings.push(`Extension target resolved to chrome-error page: ${target.url}`);
|
|
78
91
|
}
|
|
79
|
-
|
|
92
|
+
let session;
|
|
80
93
|
try {
|
|
81
|
-
await
|
|
82
|
-
|
|
83
|
-
|
|
94
|
+
session = await CdpSession.connect(target.webSocketDebuggerUrl, {
|
|
95
|
+
timeoutMs: cdpCallTimeoutMs
|
|
96
|
+
});
|
|
97
|
+
await boundedCdpCall(session, "Runtime.enable", {}, cdpCallTimeoutMs);
|
|
98
|
+
await boundedCdpCall(session, "Page.enable", {}, cdpCallTimeoutMs);
|
|
99
|
+
const compositor = await probeCdpCompositorInteractivity(
|
|
100
|
+
session,
|
|
101
|
+
Math.min(1e3, cdpCallTimeoutMs)
|
|
102
|
+
);
|
|
103
|
+
if (compositor.status === "suspended") {
|
|
104
|
+
const userAction = compositorRecoveryAction(projectRoot);
|
|
105
|
+
return {
|
|
106
|
+
status: "FAIL",
|
|
107
|
+
errorCode: "UI_COMPOSITOR_SUSPENDED",
|
|
108
|
+
userAction,
|
|
109
|
+
cdpPort,
|
|
110
|
+
targetUrl: target.url,
|
|
111
|
+
extensionId: safeExtensionId(target),
|
|
112
|
+
extensionPageTargets: extensionTargets.length,
|
|
113
|
+
findings: [
|
|
114
|
+
`UI_COMPOSITOR_SUSPENDED: the Extension page is reachable over CDP but its render compositor is not advancing (${compositor.reason ?? "unknown reason"}).`
|
|
115
|
+
],
|
|
116
|
+
details: {
|
|
117
|
+
projectRoot,
|
|
118
|
+
targetUrls: targets.map((entry) => entry.url).filter(Boolean),
|
|
119
|
+
compositor
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
if (compositor.status === "not-interactive") {
|
|
124
|
+
return {
|
|
125
|
+
status: "FAIL",
|
|
126
|
+
errorCode: "UI_NOT_INTERACTIVE",
|
|
127
|
+
userAction: `Inspect the visible Extension page for an overlay or blocked control, then rerun: mm-harness verify --target ${projectRoot}`,
|
|
128
|
+
cdpPort,
|
|
129
|
+
targetUrl: target.url,
|
|
130
|
+
extensionId: safeExtensionId(target),
|
|
131
|
+
extensionPageTargets: extensionTargets.length,
|
|
132
|
+
findings: [
|
|
133
|
+
`UI_NOT_INTERACTIVE: the Extension page is rendering, but its visible controls failed browser hit testing (${compositor.reason ?? "unknown reason"}).`
|
|
134
|
+
],
|
|
135
|
+
details: {
|
|
136
|
+
projectRoot,
|
|
137
|
+
targetUrls: targets.map((entry) => entry.url).filter(Boolean),
|
|
138
|
+
compositor
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
const runtime = await evaluateHealth(session, cdpCallTimeoutMs);
|
|
84
143
|
if (runtime.href && !String(runtime.href).startsWith("chrome-extension://")) {
|
|
85
144
|
findings.push(`Extension page href is not an extension URL: ${runtime.href}`);
|
|
86
145
|
}
|
|
@@ -114,21 +173,48 @@ async function checkExtensionRuntimeHealth(projectRoot, cdpPort, options = {}) {
|
|
|
114
173
|
runtime
|
|
115
174
|
}
|
|
116
175
|
};
|
|
176
|
+
} catch (error) {
|
|
177
|
+
return failReport(
|
|
178
|
+
cdpPort,
|
|
179
|
+
[`Extension page CDP probe failed: ${messageOf(error)}`],
|
|
180
|
+
{
|
|
181
|
+
targetUrl: target.url,
|
|
182
|
+
targetUrls: targets.map((entry) => entry.url).filter(Boolean)
|
|
183
|
+
},
|
|
184
|
+
extensionTargets.length
|
|
185
|
+
);
|
|
117
186
|
} finally {
|
|
118
|
-
session
|
|
187
|
+
session?.close();
|
|
119
188
|
}
|
|
120
189
|
}
|
|
121
|
-
function failReport(cdpPort, findings, details) {
|
|
190
|
+
function failReport(cdpPort, findings, details, extensionPageTargets = 0) {
|
|
122
191
|
return {
|
|
123
192
|
status: "FAIL",
|
|
124
193
|
cdpPort,
|
|
125
|
-
extensionPageTargets
|
|
194
|
+
extensionPageTargets,
|
|
126
195
|
findings,
|
|
127
196
|
details
|
|
128
197
|
};
|
|
129
198
|
}
|
|
130
|
-
async function
|
|
131
|
-
|
|
199
|
+
async function boundedCdpCall(session, method, params, timeoutMs) {
|
|
200
|
+
let timer;
|
|
201
|
+
try {
|
|
202
|
+
return await Promise.race([
|
|
203
|
+
session.call(method, params),
|
|
204
|
+
new Promise((_resolve, reject) => {
|
|
205
|
+
timer = setTimeout(
|
|
206
|
+
() => reject(new Error(`${method} timed out after ${timeoutMs}ms.`)),
|
|
207
|
+
timeoutMs
|
|
208
|
+
);
|
|
209
|
+
})
|
|
210
|
+
]);
|
|
211
|
+
} finally {
|
|
212
|
+
if (timer) clearTimeout(timer);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
async function evaluateHealth(session, timeoutMs) {
|
|
216
|
+
const backgroundProbeTimeoutMs = extensionBackgroundProbeTimeoutMs(timeoutMs);
|
|
217
|
+
const result = await boundedCdpCall(session, "Runtime.evaluate", {
|
|
132
218
|
expression: `(() => {
|
|
133
219
|
const hooks = globalThis.stateHooks || {};
|
|
134
220
|
const bodyText = document.body?.innerText || '';
|
|
@@ -177,13 +263,13 @@ async function evaluateHealth(session) {
|
|
|
177
263
|
hasPerpsStreamManager: typeof hooks.getPerpsStreamManager === 'function',
|
|
178
264
|
backgroundUnresponsive: bodyText.includes('Background connection unresponsive') || bodyText.includes('MetaMask had trouble starting'),
|
|
179
265
|
backgroundProbeOk: false,
|
|
180
|
-
backgroundProbeError: 'perpsGetAccountState timed out after
|
|
181
|
-
}),
|
|
266
|
+
backgroundProbeError: 'perpsGetAccountState timed out after ${backgroundProbeTimeoutMs}ms',
|
|
267
|
+
}), ${backgroundProbeTimeoutMs})),
|
|
182
268
|
]);
|
|
183
269
|
})()`,
|
|
184
270
|
awaitPromise: true,
|
|
185
271
|
returnByValue: true
|
|
186
|
-
});
|
|
272
|
+
}, timeoutMs);
|
|
187
273
|
if (result.exceptionDetails) {
|
|
188
274
|
throw new Error(
|
|
189
275
|
result.exceptionDetails.exception?.description ?? result.exceptionDetails.text ?? "Extension runtime health evaluation failed."
|
|
@@ -191,15 +277,24 @@ async function evaluateHealth(session) {
|
|
|
191
277
|
}
|
|
192
278
|
return result.result?.value ?? {};
|
|
193
279
|
}
|
|
280
|
+
function extensionBackgroundProbeTimeoutMs(cdpCallTimeoutMs) {
|
|
281
|
+
return Math.max(1, Math.floor(cdpCallTimeoutMs * 0.8));
|
|
282
|
+
}
|
|
194
283
|
function formatHealthFailure(report, projectRoot) {
|
|
195
284
|
return [
|
|
196
285
|
`Extension runtime health check failed for ${projectRoot} on CDP port ${report.cdpPort}.`,
|
|
197
286
|
...report.findings.map((finding) => `- ${finding}`),
|
|
198
|
-
|
|
199
|
-
|
|
287
|
+
...report.userAction ? ["Next:", ` ${report.userAction}`] : [
|
|
288
|
+
"Use the host-managed validation launcher, for example:",
|
|
289
|
+
` mm-harness run <recipe.json> --adapter extension --target ${projectRoot} --slot <slot-id> --cdp-port ${report.cdpPort} --launch-existing-dist --artifacts-dir <artifacts-dir>`
|
|
290
|
+
],
|
|
200
291
|
`Health details: ${JSON.stringify(report.details)}`
|
|
201
292
|
].join("\n");
|
|
202
293
|
}
|
|
294
|
+
function compositorRecoveryAction(projectRoot) {
|
|
295
|
+
const recovery = `mm-harness launch --verify --target ${JSON.stringify(projectRoot)}`;
|
|
296
|
+
return process.platform === "darwin" ? `Unlock the macOS session, then rerun: ${recovery}` : `Restore an active display compositor, then rerun: ${recovery}`;
|
|
297
|
+
}
|
|
203
298
|
function resolveCdpPort(rawPort, slot) {
|
|
204
299
|
const raw = rawPort ?? process.env.CDP_PORT ?? process.env.RECIPE_CDP_PORT ?? slot?.cdpPort;
|
|
205
300
|
const port = Number(raw);
|
|
@@ -329,6 +424,8 @@ function messageOf(error) {
|
|
|
329
424
|
export {
|
|
330
425
|
assertHealthyExtensionRuntime,
|
|
331
426
|
checkExtensionRuntimeHealth,
|
|
427
|
+
compositorRecoveryAction,
|
|
428
|
+
extensionBackgroundProbeTimeoutMs,
|
|
332
429
|
formatHealthFailure,
|
|
333
430
|
prepareExtensionRuntime
|
|
334
431
|
};
|
|
@@ -32,6 +32,7 @@ async function prepareMobile(target, opts = {}) {
|
|
|
32
32
|
const platform = opts.platform ?? "ios";
|
|
33
33
|
const preflightMode = opts.preflightMode ?? "fast";
|
|
34
34
|
const clearMetro = opts.clearMetro ?? false;
|
|
35
|
+
const restartApp = opts.restartApp ?? false;
|
|
35
36
|
const report = await decideMobileReadiness(target, {
|
|
36
37
|
watcherPort: opts.watcherPort,
|
|
37
38
|
metroLog: opts.metroLog,
|
|
@@ -49,6 +50,16 @@ async function prepareMobile(target, opts = {}) {
|
|
|
49
50
|
return { status: EXIT.runtime, output: msg };
|
|
50
51
|
}
|
|
51
52
|
if (report.decision === "ready") {
|
|
53
|
+
if (restartApp) {
|
|
54
|
+
const launch2 = await dispatchActionSequence(
|
|
55
|
+
withAppRestart(launchActions(path.resolve(target)), true),
|
|
56
|
+
target,
|
|
57
|
+
platform,
|
|
58
|
+
json,
|
|
59
|
+
preflightMode
|
|
60
|
+
);
|
|
61
|
+
return launch2.status === 0 ? startMobileConsoleForwarder(target, platform, json) : launch2;
|
|
62
|
+
}
|
|
52
63
|
const surface = await ensureMobileHumanSurface(target, platform, json);
|
|
53
64
|
if (surface.status !== 0) return surface;
|
|
54
65
|
const confirm = await dispatchAction(
|
|
@@ -66,7 +77,7 @@ async function prepareMobile(target, opts = {}) {
|
|
|
66
77
|
);
|
|
67
78
|
}
|
|
68
79
|
const launch = await dispatchActionSequence(
|
|
69
|
-
launchActions(path.resolve(target)),
|
|
80
|
+
withAppRestart(launchActions(path.resolve(target)), restartApp),
|
|
70
81
|
target,
|
|
71
82
|
platform,
|
|
72
83
|
json,
|
|
@@ -80,9 +91,12 @@ async function prepareMobile(target, opts = {}) {
|
|
|
80
91
|
`);
|
|
81
92
|
return { status: EXIT.runtime, output: msg };
|
|
82
93
|
}
|
|
83
|
-
const actions =
|
|
84
|
-
|
|
85
|
-
|
|
94
|
+
const actions = withAppRestart(
|
|
95
|
+
clearMetro ? report.actions.map(
|
|
96
|
+
(a) => a.id === "start-metro" && !a.argv?.includes("--clear") ? { ...a, argv: [...a.argv ?? [], "--clear"] } : a
|
|
97
|
+
) : report.actions,
|
|
98
|
+
restartApp
|
|
99
|
+
);
|
|
86
100
|
const actionResult = await dispatchActionSequence(actions, target, platform, json, preflightMode);
|
|
87
101
|
if (actionResult.status !== 0) return actionResult;
|
|
88
102
|
if (report.decision === "install" && !process.env["RECIPE_UP_INSTALL_ATTEMPTED"]) {
|
|
@@ -109,7 +123,13 @@ async function prepareMobile(target, opts = {}) {
|
|
|
109
123
|
break;
|
|
110
124
|
}
|
|
111
125
|
case "launch": {
|
|
112
|
-
const result = await dispatchActionSequence(
|
|
126
|
+
const result = await dispatchActionSequence(
|
|
127
|
+
withAppRestart(postInstall.actions, restartApp),
|
|
128
|
+
target,
|
|
129
|
+
platform,
|
|
130
|
+
json,
|
|
131
|
+
preflightMode
|
|
132
|
+
);
|
|
113
133
|
if (result.status !== 0) return result;
|
|
114
134
|
break;
|
|
115
135
|
}
|
|
@@ -136,6 +156,12 @@ function ensureMobileHumanSurface(target, platform, json) {
|
|
|
136
156
|
if (platform !== "ios") return Promise.resolve({ status: 0, output: "" });
|
|
137
157
|
return dispatchAction({ id: "ensure-device-ui", cwd: target }, target, platform, json);
|
|
138
158
|
}
|
|
159
|
+
function withAppRestart(actions, restartApp) {
|
|
160
|
+
if (!restartApp) return actions;
|
|
161
|
+
return actions.map(
|
|
162
|
+
(action) => action.id === "launch-mobile-runtime" ? { ...action, argv: [...action.argv ?? [], "--restart"] } : action
|
|
163
|
+
);
|
|
164
|
+
}
|
|
139
165
|
async function dispatchActionSequence(requestedActions, target, platform, json, preflightMode) {
|
|
140
166
|
const resolved = path.resolve(target);
|
|
141
167
|
const actions = applyMobileMetroEnvPolicy(resolved, requestedActions);
|
|
@@ -187,7 +213,15 @@ async function dispatchAction(action, target, platform, json, preflightMode = "f
|
|
|
187
213
|
const leaf = path.join(runnerDir, "adapters/mobile/open-device.sh");
|
|
188
214
|
return spawnScriptStreaming(
|
|
189
215
|
leaf,
|
|
190
|
-
[
|
|
216
|
+
[
|
|
217
|
+
"--platform",
|
|
218
|
+
platform,
|
|
219
|
+
"--target",
|
|
220
|
+
cwd,
|
|
221
|
+
"--preflight-mode",
|
|
222
|
+
preflightMode,
|
|
223
|
+
...action.argv ?? []
|
|
224
|
+
],
|
|
191
225
|
target,
|
|
192
226
|
POD_PROBE_ENV
|
|
193
227
|
);
|
package/dist/command-contract.js
CHANGED
|
@@ -58,8 +58,8 @@ const PUBLIC_COMMAND_CONTRACTS = {
|
|
|
58
58
|
})
|
|
59
59
|
},
|
|
60
60
|
checklist: {
|
|
61
|
-
usage: "mm-harness checklist mark <task-dir> <step> [options]",
|
|
62
|
-
options: options(HELP, {
|
|
61
|
+
usage: "mm-harness checklist <mark <task-dir> <step> | closeout <task-dir>> [options]",
|
|
62
|
+
options: options(HELP, JSON, {
|
|
63
63
|
"--mark-last": bool(),
|
|
64
64
|
"--already-fixed": bool(),
|
|
65
65
|
"--no-self-review": bool(),
|
|
@@ -67,10 +67,14 @@ const PUBLIC_COMMAND_CONTRACTS = {
|
|
|
67
67
|
"--skip-checklist": bool(),
|
|
68
68
|
"--reason": value(),
|
|
69
69
|
"--checklist": value(),
|
|
70
|
-
"--signal": value()
|
|
70
|
+
"--signal": value(),
|
|
71
|
+
"--share": bool(),
|
|
72
|
+
"--config": value(),
|
|
73
|
+
"--destination": value(),
|
|
74
|
+
"--metadata": value()
|
|
71
75
|
}),
|
|
72
76
|
positionals: [
|
|
73
|
-
{ label: "action", choices: ["mark"] },
|
|
77
|
+
{ label: "action", choices: ["mark", "closeout"] },
|
|
74
78
|
{ label: "task-dir" },
|
|
75
79
|
{
|
|
76
80
|
label: "step",
|
|
@@ -82,7 +86,7 @@ const PUBLIC_COMMAND_CONTRACTS = {
|
|
|
82
86
|
validDescription: "start|complete|no-change|blocked|a positive numeric step"
|
|
83
87
|
}
|
|
84
88
|
],
|
|
85
|
-
minimumPositionals:
|
|
89
|
+
minimumPositionals: 2
|
|
86
90
|
},
|
|
87
91
|
actions: {
|
|
88
92
|
options: options(HELP, JSON, TARGET, ADAPTER, ADAPTER_OR_MOBILE_PLATFORM, {
|
|
@@ -307,6 +311,55 @@ function validatePublicInvocation(argv, examples = {}) {
|
|
|
307
311
|
}
|
|
308
312
|
}
|
|
309
313
|
if (seenOptions.has("--help") || seenOptions.has("-h")) return null;
|
|
314
|
+
if (name === "checklist") {
|
|
315
|
+
const action = positionals[0];
|
|
316
|
+
const markOnly = [
|
|
317
|
+
"--mark-last",
|
|
318
|
+
"--already-fixed",
|
|
319
|
+
"--no-self-review",
|
|
320
|
+
"--skip-learnings",
|
|
321
|
+
"--skip-checklist",
|
|
322
|
+
"--reason",
|
|
323
|
+
"--checklist",
|
|
324
|
+
"--signal"
|
|
325
|
+
];
|
|
326
|
+
const closeoutOnly = [
|
|
327
|
+
"--json",
|
|
328
|
+
"--share",
|
|
329
|
+
"--config",
|
|
330
|
+
"--destination",
|
|
331
|
+
"--metadata"
|
|
332
|
+
];
|
|
333
|
+
const invalidOption = (action === "mark" ? closeoutOnly : markOnly).find(
|
|
334
|
+
(option) => seenOptions.has(option)
|
|
335
|
+
);
|
|
336
|
+
if (invalidOption && (action === "mark" || action === "closeout")) {
|
|
337
|
+
return usageFailure(
|
|
338
|
+
"CLI_UNKNOWN_OPTION",
|
|
339
|
+
name,
|
|
340
|
+
`${invalidOption} is not valid with checklist ${action}.`,
|
|
341
|
+
contract,
|
|
342
|
+
examples
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
if (action === "mark" && positionals.length < 3) {
|
|
346
|
+
return missingPositionalFailure(
|
|
347
|
+
name,
|
|
348
|
+
"checklist mark requires <step>.",
|
|
349
|
+
contract,
|
|
350
|
+
examples
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
if (action === "closeout" && positionals.length > 2) {
|
|
354
|
+
return usageFailure(
|
|
355
|
+
"CLI_EXCESS_POSITIONAL",
|
|
356
|
+
name,
|
|
357
|
+
`unexpected positional '${positionals[2]}'; checklist closeout accepts only <task-dir>.`,
|
|
358
|
+
contract,
|
|
359
|
+
examples
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
310
363
|
if (contract.leadingPositionals && !contract.requiredUnless?.some((option) => seenOptions.has(option)) && tokens.slice(0, contract.leadingPositionals).some((argument) => !argument || argument.startsWith("-"))) {
|
|
311
364
|
const missing = contract.positionals?.[0]?.label ?? "argument";
|
|
312
365
|
return missingPositionalFailure(name, `${name} requires <${missing}> first.`, contract, examples);
|