@deeeed/metamask-harness 0.3.9 → 0.5.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 +45 -1
- package/dist/adapters/core/surface.js +53 -0
- package/dist/adapters/extension/ensure-ready.js +109 -0
- package/dist/adapters/extension/extension-id.js +62 -0
- package/dist/adapters/extension/runtime-decision.js +305 -0
- package/dist/adapters/extension/runtime.js +324 -0
- package/dist/adapters/extension/surface.js +69 -0
- package/dist/adapters/mobile/deps-markers.js +22 -0
- package/dist/adapters/mobile/prepare.js +146 -0
- package/dist/adapters/mobile/provision.js +465 -0
- package/dist/adapters/mobile/runtime-decision.js +315 -0
- package/dist/adapters/mobile/surface.js +54 -0
- package/dist/adapters/slot-ports.js +146 -0
- package/dist/adapters/surface.js +14 -0
- package/dist/adapters.js +485 -0
- package/dist/cli-color.js +79 -0
- package/dist/cli-commands.js +224 -0
- package/dist/cli-version.js +111 -0
- package/dist/cli.js +1571 -0
- package/dist/commands/debug.js +56 -0
- package/dist/commands/fixtures.js +153 -0
- package/dist/commands/launch.js +325 -0
- package/dist/commands/logs.js +73 -0
- package/dist/commands/shared.js +157 -0
- package/dist/commands/update.js +243 -0
- package/dist/completions-cache.js +53 -0
- package/dist/doctor.js +169 -0
- package/dist/harness.js +627 -0
- package/dist/heal-bounds.js +120 -0
- package/dist/index.js +25 -0
- package/dist/leaf-invoke.js +19 -0
- package/dist/live-adapter-contract.js +240 -0
- package/dist/manifest.js +37 -0
- package/dist/mm-harness-cli.js +521 -0
- package/dist/paths.js +179 -0
- package/dist/progress.js +94 -0
- package/dist/recording-target.js +133 -0
- package/dist/run-recording.js +271 -0
- package/dist/runner.js +88 -0
- package/dist/types.js +0 -0
- package/docs/ADAPTER-SURFACE.md +119 -0
- package/docs/CLI-SPEC.md +26 -3
- package/docs/UX-PRINCIPLES.md +3 -0
- package/package.json +10 -2
- package/src/adapters/core/surface.ts +71 -0
- package/src/adapters/extension/surface.ts +88 -0
- package/src/adapters/mobile/provision.ts +594 -0
- package/src/adapters/mobile/surface.ts +71 -0
- package/src/adapters/slot-ports.ts +165 -0
- package/src/adapters/surface.ts +117 -0
- package/src/cli-commands.ts +1 -1
- package/src/cli.ts +239 -49
- package/src/commands/debug.ts +3 -1
- package/src/commands/fixtures.ts +13 -8
- package/src/commands/launch.ts +7 -156
- package/src/commands/logs.ts +29 -13
- package/src/harness.ts +140 -3
- package/src/mm-harness-cli.ts +71 -18
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { importRecipeHarnessRuntimeBrowserExtension, importRecipeHarnessRuntimeCdp, recipeRuntimeDir, resolveLocalProtocolRoot, resolveRequiredLocalProtocolRoot, runnerDir } from "../../paths.js";
|
|
5
|
+
const { CdpSession, jsonGet, sleep } = await importRecipeHarnessRuntimeCdp();
|
|
6
|
+
const { extensionIdFromTarget } = await importRecipeHarnessRuntimeBrowserExtension();
|
|
7
|
+
async function prepareExtensionRuntime(options) {
|
|
8
|
+
const projectRoot = path.resolve(options.projectRoot);
|
|
9
|
+
const slot = resolveExtensionSlot(projectRoot, options.slot);
|
|
10
|
+
const cdpPort = resolveCdpPort(options.cdpPort, slot);
|
|
11
|
+
let launch = null;
|
|
12
|
+
if (options.launchExistingDist) {
|
|
13
|
+
if (!slot) {
|
|
14
|
+
throw new Error(
|
|
15
|
+
`Cannot launch host-managed Extension runtime for ${projectRoot}: no configured slot maps to this repo. Pass --slot <slot-id> or add the checkout to pool/*.json.`
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
launch = await launchHostValidationBrowser({
|
|
19
|
+
projectRoot,
|
|
20
|
+
cdpPort,
|
|
21
|
+
slotId: slot.id,
|
|
22
|
+
validationRuntimeDir: options.validationRuntimeDir ?? `${recipeRuntimeDir()}/validation-${cdpPort}`
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
const health = await assertHealthyExtensionRuntime({
|
|
26
|
+
projectRoot,
|
|
27
|
+
cdpPort,
|
|
28
|
+
timeoutMs: options.healthTimeoutMs
|
|
29
|
+
});
|
|
30
|
+
return { launch, health };
|
|
31
|
+
}
|
|
32
|
+
async function assertHealthyExtensionRuntime(options) {
|
|
33
|
+
const timeoutMs = options.timeoutMs ?? 3e4;
|
|
34
|
+
const deadline = Date.now() + timeoutMs;
|
|
35
|
+
let lastReport = null;
|
|
36
|
+
while (Date.now() <= deadline) {
|
|
37
|
+
lastReport = await checkExtensionRuntimeHealth(options.projectRoot, options.cdpPort);
|
|
38
|
+
if (lastReport.status === "PASS") return lastReport;
|
|
39
|
+
await sleep(500);
|
|
40
|
+
}
|
|
41
|
+
const report = lastReport ?? failReport(options.cdpPort, ["CDP runtime was not probed."], {});
|
|
42
|
+
throw new Error(formatHealthFailure(report, options.projectRoot));
|
|
43
|
+
}
|
|
44
|
+
async function checkExtensionRuntimeHealth(projectRoot, cdpPort) {
|
|
45
|
+
const findings = [];
|
|
46
|
+
let targets = [];
|
|
47
|
+
try {
|
|
48
|
+
await jsonGet(`http://127.0.0.1:${cdpPort}/json/version`);
|
|
49
|
+
const rawTargets = await jsonGet(`http://127.0.0.1:${cdpPort}/json/list`);
|
|
50
|
+
targets = Array.isArray(rawTargets) ? rawTargets : [];
|
|
51
|
+
} catch (error) {
|
|
52
|
+
return failReport(cdpPort, [`CDP is not reachable on port ${cdpPort}: ${messageOf(error)}`], {});
|
|
53
|
+
}
|
|
54
|
+
const extensionTargets = targets.filter(
|
|
55
|
+
(target2) => target2.type === "page" && String(target2.url ?? "").startsWith("chrome-extension://") && String(target2.url ?? "").includes("/home.html") && Boolean(target2.webSocketDebuggerUrl)
|
|
56
|
+
);
|
|
57
|
+
if (extensionTargets.length !== 1) {
|
|
58
|
+
findings.push(`Expected exactly one MetaMask extension home page target, found ${extensionTargets.length}.`);
|
|
59
|
+
}
|
|
60
|
+
const target = extensionTargets[0];
|
|
61
|
+
if (!target?.webSocketDebuggerUrl) {
|
|
62
|
+
return failReport(cdpPort, findings, {
|
|
63
|
+
targetUrls: targets.map((entry) => entry.url).filter(Boolean)
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
if (String(target.url ?? "").startsWith("chrome-error://")) {
|
|
67
|
+
findings.push(`Extension target resolved to chrome-error page: ${target.url}`);
|
|
68
|
+
}
|
|
69
|
+
const session = await CdpSession.connect(target.webSocketDebuggerUrl);
|
|
70
|
+
try {
|
|
71
|
+
await session.call("Runtime.enable");
|
|
72
|
+
await session.call("Page.enable");
|
|
73
|
+
const runtime = await evaluateHealth(session);
|
|
74
|
+
if (runtime.href && !String(runtime.href).startsWith("chrome-extension://")) {
|
|
75
|
+
findings.push(`Extension page href is not an extension URL: ${runtime.href}`);
|
|
76
|
+
}
|
|
77
|
+
if (runtime.backgroundUnresponsive === true) {
|
|
78
|
+
findings.push("Extension UI reports background connection unresponsive.");
|
|
79
|
+
}
|
|
80
|
+
if (runtime.hasSubmitRequest !== true) {
|
|
81
|
+
findings.push("stateHooks.submitRequestToBackground is unavailable.");
|
|
82
|
+
}
|
|
83
|
+
if (runtime.hasStore !== true) {
|
|
84
|
+
findings.push("stateHooks.store is unavailable.");
|
|
85
|
+
}
|
|
86
|
+
if (runtime.hasPerpsStreamManager !== true) {
|
|
87
|
+
findings.push("stateHooks.getPerpsStreamManager is unavailable.");
|
|
88
|
+
}
|
|
89
|
+
if (runtime.backgroundProbeOk !== true) {
|
|
90
|
+
findings.push(`Perps background read probe failed: ${runtime.backgroundProbeError ?? "unknown error"}.`);
|
|
91
|
+
}
|
|
92
|
+
const extensionId = safeExtensionId(target);
|
|
93
|
+
return {
|
|
94
|
+
status: findings.length === 0 ? "PASS" : "FAIL",
|
|
95
|
+
cdpPort,
|
|
96
|
+
targetUrl: target.url,
|
|
97
|
+
extensionId,
|
|
98
|
+
extensionPageTargets: extensionTargets.length,
|
|
99
|
+
findings,
|
|
100
|
+
details: {
|
|
101
|
+
projectRoot,
|
|
102
|
+
targetUrls: targets.map((entry) => entry.url).filter(Boolean),
|
|
103
|
+
runtime
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
} finally {
|
|
107
|
+
session.close();
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
function failReport(cdpPort, findings, details) {
|
|
111
|
+
return {
|
|
112
|
+
status: "FAIL",
|
|
113
|
+
cdpPort,
|
|
114
|
+
extensionPageTargets: 0,
|
|
115
|
+
findings,
|
|
116
|
+
details
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
async function evaluateHealth(session) {
|
|
120
|
+
const result = await session.call("Runtime.evaluate", {
|
|
121
|
+
expression: `(() => {
|
|
122
|
+
const hooks = globalThis.stateHooks || {};
|
|
123
|
+
const bodyText = document.body?.innerText || '';
|
|
124
|
+
const storeState = hooks.store?.getState?.() || {};
|
|
125
|
+
const manager = hooks.getPerpsStreamManager?.();
|
|
126
|
+
const accountCache = manager?.account?.cache;
|
|
127
|
+
return Promise.race([
|
|
128
|
+
(async () => {
|
|
129
|
+
let backgroundProbeOk = false;
|
|
130
|
+
let backgroundProbeError = null;
|
|
131
|
+
if (typeof hooks.submitRequestToBackground === 'function') {
|
|
132
|
+
try {
|
|
133
|
+
const accountState = await hooks.submitRequestToBackground('perpsGetAccountState', []);
|
|
134
|
+
backgroundProbeOk = Boolean(accountState && typeof accountState === 'object');
|
|
135
|
+
if (!backgroundProbeOk) backgroundProbeError = 'perpsGetAccountState returned an empty result';
|
|
136
|
+
} catch (error) {
|
|
137
|
+
backgroundProbeError = String(error?.message || error);
|
|
138
|
+
}
|
|
139
|
+
} else {
|
|
140
|
+
backgroundProbeError = 'submitRequestToBackground is not a function';
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
href: location.href,
|
|
144
|
+
title: document.title,
|
|
145
|
+
hookKeys: Object.keys(hooks),
|
|
146
|
+
hasSubmitRequest: typeof hooks.submitRequestToBackground === 'function',
|
|
147
|
+
hasStore: Boolean(hooks.store),
|
|
148
|
+
hasPerpsStreamManager: typeof hooks.getPerpsStreamManager === 'function',
|
|
149
|
+
backgroundUnresponsive: bodyText.includes('Background connection unresponsive') || bodyText.includes('MetaMask had trouble starting'),
|
|
150
|
+
activeProvider: storeState.metamask?.activeProvider || null,
|
|
151
|
+
isTestnet: Boolean(storeState.metamask?.isTestnet),
|
|
152
|
+
perpsManagerInitialized: Boolean(manager?.isInitialized?.()),
|
|
153
|
+
positionsCacheIsArray: Array.isArray(manager?.positions?.cache),
|
|
154
|
+
ordersCacheIsArray: Array.isArray(manager?.orders?.cache),
|
|
155
|
+
accountCachePresent: Boolean(accountCache && typeof accountCache === 'object'),
|
|
156
|
+
backgroundProbeOk,
|
|
157
|
+
backgroundProbeError,
|
|
158
|
+
};
|
|
159
|
+
})(),
|
|
160
|
+
new Promise((resolve) => setTimeout(() => resolve({
|
|
161
|
+
href: location.href,
|
|
162
|
+
title: document.title,
|
|
163
|
+
hookKeys: Object.keys(hooks),
|
|
164
|
+
hasSubmitRequest: typeof hooks.submitRequestToBackground === 'function',
|
|
165
|
+
hasStore: Boolean(hooks.store),
|
|
166
|
+
hasPerpsStreamManager: typeof hooks.getPerpsStreamManager === 'function',
|
|
167
|
+
backgroundUnresponsive: bodyText.includes('Background connection unresponsive') || bodyText.includes('MetaMask had trouble starting'),
|
|
168
|
+
backgroundProbeOk: false,
|
|
169
|
+
backgroundProbeError: 'perpsGetAccountState timed out after 5000ms',
|
|
170
|
+
}), 5000)),
|
|
171
|
+
]);
|
|
172
|
+
})()`,
|
|
173
|
+
awaitPromise: true,
|
|
174
|
+
returnByValue: true
|
|
175
|
+
});
|
|
176
|
+
if (result.exceptionDetails) {
|
|
177
|
+
throw new Error(
|
|
178
|
+
result.exceptionDetails.exception?.description ?? result.exceptionDetails.text ?? "Extension runtime health evaluation failed."
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
return result.result?.value ?? {};
|
|
182
|
+
}
|
|
183
|
+
function formatHealthFailure(report, projectRoot) {
|
|
184
|
+
return [
|
|
185
|
+
`Extension runtime health check failed for ${projectRoot} on CDP port ${report.cdpPort}.`,
|
|
186
|
+
...report.findings.map((finding) => `- ${finding}`),
|
|
187
|
+
"Use the host-managed validation launcher, for example:",
|
|
188
|
+
` mm-harness run <recipe.json> --adapter extension --target ${projectRoot} --slot <slot-id> --cdp-port ${report.cdpPort} --launch-existing-dist --artifacts-dir <artifacts-dir>`,
|
|
189
|
+
`Health details: ${JSON.stringify(report.details)}`
|
|
190
|
+
].join("\n");
|
|
191
|
+
}
|
|
192
|
+
function resolveCdpPort(rawPort, slot) {
|
|
193
|
+
const raw = rawPort ?? process.env.CDP_PORT ?? process.env.RECIPE_CDP_PORT ?? slot?.cdpPort;
|
|
194
|
+
const port = Number(raw);
|
|
195
|
+
if (!Number.isInteger(port) || port <= 0) {
|
|
196
|
+
throw new Error("Extension runtime requires --cdp-port, CDP_PORT, RECIPE_CDP_PORT, or a configured slot with resources.browser.cdp_port.");
|
|
197
|
+
}
|
|
198
|
+
return port;
|
|
199
|
+
}
|
|
200
|
+
function resolveExtensionSlot(projectRoot, requestedSlot) {
|
|
201
|
+
const protocolRoot = resolveLocalProtocolRoot();
|
|
202
|
+
if (!protocolRoot) {
|
|
203
|
+
if (requestedSlot) throw new Error("Extension slot lookup requires a local host checkout when --slot is provided.");
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
const poolsDir = path.join(protocolRoot, "pool");
|
|
207
|
+
if (!fs.existsSync(poolsDir)) return null;
|
|
208
|
+
for (const file of fs.readdirSync(poolsDir)) {
|
|
209
|
+
if (!file.endsWith(".json") || file.includes(".bak")) continue;
|
|
210
|
+
const pool = JSON.parse(fs.readFileSync(path.join(poolsDir, file), "utf8"));
|
|
211
|
+
for (const rawSlot of pool.slots ?? []) {
|
|
212
|
+
if (!rawSlot || typeof rawSlot !== "object") continue;
|
|
213
|
+
const slot = rawSlot;
|
|
214
|
+
if (requestedSlot && slot.id !== requestedSlot) continue;
|
|
215
|
+
if (!requestedSlot) {
|
|
216
|
+
const repo = String(slot.repo ?? "");
|
|
217
|
+
if (!path.isAbsolute(repo) || path.resolve(repo) !== projectRoot) continue;
|
|
218
|
+
}
|
|
219
|
+
return {
|
|
220
|
+
id: String(slot.id),
|
|
221
|
+
repo: path.resolve(String(slot.repo)),
|
|
222
|
+
cdpPort: Number(slot.resources?.browser?.cdp_port || slot.resources?.browser?.cdpPort || void 0) || void 0
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
async function launchHostValidationBrowser(options) {
|
|
229
|
+
const protocolRoot = resolveRequiredLocalProtocolRoot("Extension validation browser launch");
|
|
230
|
+
const script = path.join(protocolRoot, "projects/metamask-extension-farm/setup/launch-validation-browser.sh");
|
|
231
|
+
if (!fs.existsSync(script)) {
|
|
232
|
+
throw new Error(`Extension validation launcher not found: ${script}`);
|
|
233
|
+
}
|
|
234
|
+
await killCdpPort(options.cdpPort);
|
|
235
|
+
const result = await runProcess("bash", [
|
|
236
|
+
script,
|
|
237
|
+
options.slotId,
|
|
238
|
+
"--cdp-port",
|
|
239
|
+
String(options.cdpPort),
|
|
240
|
+
"--runtime-dir",
|
|
241
|
+
options.validationRuntimeDir,
|
|
242
|
+
"--no-landing"
|
|
243
|
+
], {
|
|
244
|
+
cwd: protocolRoot,
|
|
245
|
+
env: process.env,
|
|
246
|
+
timeoutMs: 18e4
|
|
247
|
+
});
|
|
248
|
+
if (result.timedOut) {
|
|
249
|
+
throw new Error(`Extension validation launcher timed out after 180000ms.
|
|
250
|
+
${result.stdout}
|
|
251
|
+
${result.stderr}`);
|
|
252
|
+
}
|
|
253
|
+
if (result.exitCode !== 0) {
|
|
254
|
+
throw new Error(`Extension validation launcher failed with exit ${result.exitCode}.
|
|
255
|
+
${result.stdout}
|
|
256
|
+
${result.stderr}`);
|
|
257
|
+
}
|
|
258
|
+
return {
|
|
259
|
+
launched: true,
|
|
260
|
+
slotId: options.slotId,
|
|
261
|
+
cdpPort: options.cdpPort,
|
|
262
|
+
runtimeDir: options.validationRuntimeDir,
|
|
263
|
+
stdout: result.stdout
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
function runProcess(command, args, options) {
|
|
267
|
+
return new Promise((resolve, reject) => {
|
|
268
|
+
const child = spawn(command, args, { cwd: options.cwd, env: options.env, stdio: ["ignore", "pipe", "pipe"] });
|
|
269
|
+
let stdout = "";
|
|
270
|
+
let stderr = "";
|
|
271
|
+
let settled = false;
|
|
272
|
+
const timeout = options.timeoutMs ? setTimeout(() => {
|
|
273
|
+
if (settled) return;
|
|
274
|
+
settled = true;
|
|
275
|
+
child.kill("SIGTERM");
|
|
276
|
+
setTimeout(() => {
|
|
277
|
+
if (!child.killed) child.kill("SIGKILL");
|
|
278
|
+
}, 1e3);
|
|
279
|
+
resolve({ exitCode: null, stdout, stderr, timedOut: true });
|
|
280
|
+
}, options.timeoutMs) : void 0;
|
|
281
|
+
child.stdout.on("data", (chunk) => {
|
|
282
|
+
stdout += chunk;
|
|
283
|
+
});
|
|
284
|
+
child.stderr.on("data", (chunk) => {
|
|
285
|
+
stderr += chunk;
|
|
286
|
+
});
|
|
287
|
+
child.on("error", (error) => {
|
|
288
|
+
if (settled) return;
|
|
289
|
+
settled = true;
|
|
290
|
+
if (timeout) clearTimeout(timeout);
|
|
291
|
+
reject(error);
|
|
292
|
+
});
|
|
293
|
+
child.on("close", (exitCode) => {
|
|
294
|
+
if (settled) return;
|
|
295
|
+
settled = true;
|
|
296
|
+
if (timeout) clearTimeout(timeout);
|
|
297
|
+
resolve({ exitCode, stdout, stderr, timedOut: false });
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
async function killCdpPort(cdpPort) {
|
|
302
|
+
if (process.platform !== "darwin" && process.platform !== "linux") return;
|
|
303
|
+
await runProcess("bash", ["-lc", `lsof -ti tcp:${cdpPort} -sTCP:LISTEN | xargs -r kill -TERM || true; sleep 1; lsof -ti tcp:${cdpPort} -sTCP:LISTEN | xargs -r kill -KILL || true`], {
|
|
304
|
+
cwd: runnerDir,
|
|
305
|
+
env: process.env,
|
|
306
|
+
timeoutMs: 1e4
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
function safeExtensionId(target) {
|
|
310
|
+
try {
|
|
311
|
+
return extensionIdFromTarget(target);
|
|
312
|
+
} catch (_error) {
|
|
313
|
+
return void 0;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
function messageOf(error) {
|
|
317
|
+
return error instanceof Error ? error.message : String(error);
|
|
318
|
+
}
|
|
319
|
+
export {
|
|
320
|
+
assertHealthyExtensionRuntime,
|
|
321
|
+
checkExtensionRuntimeHealth,
|
|
322
|
+
formatHealthFailure,
|
|
323
|
+
prepareExtensionRuntime
|
|
324
|
+
};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { recipeRuntimePath } from "../../paths.js";
|
|
4
|
+
import { resolveExtensionSlotPorts, stopExtensionWatcher } from "../slot-ports.js";
|
|
5
|
+
import { decideExtensionReadiness } from "./runtime-decision.js";
|
|
6
|
+
function watcherStatus(buildLog) {
|
|
7
|
+
if (buildLog === "ok") return "up";
|
|
8
|
+
if (buildLog === "no-watch") return "down";
|
|
9
|
+
return buildLog;
|
|
10
|
+
}
|
|
11
|
+
const extensionSurface = {
|
|
12
|
+
adapter: "extension",
|
|
13
|
+
headless: false,
|
|
14
|
+
resolveSlotPorts(target) {
|
|
15
|
+
resolveExtensionSlotPorts(target);
|
|
16
|
+
},
|
|
17
|
+
async runtimeStatus(target) {
|
|
18
|
+
const cdpPort = process.env.CDP_PORT ? parseInt(process.env.CDP_PORT, 10) : void 0;
|
|
19
|
+
const report = await decideExtensionReadiness(target, { cdpPort });
|
|
20
|
+
return {
|
|
21
|
+
decision: report.decision,
|
|
22
|
+
reasonCode: report.reasonCode,
|
|
23
|
+
reasons: report.reasons,
|
|
24
|
+
deps: report.checks.deps.status,
|
|
25
|
+
devServer: { label: "webpack", status: watcherStatus(report.checks.buildLog.status) }
|
|
26
|
+
};
|
|
27
|
+
},
|
|
28
|
+
runwayProvision: {
|
|
29
|
+
async run(target, options) {
|
|
30
|
+
return {
|
|
31
|
+
schemaVersion: 1,
|
|
32
|
+
command: "provision",
|
|
33
|
+
adapter: "extension",
|
|
34
|
+
target: path.resolve(target),
|
|
35
|
+
status: "fail",
|
|
36
|
+
exitCode: 2,
|
|
37
|
+
error: {
|
|
38
|
+
code: "UNSUPPORTED_ADAPTER",
|
|
39
|
+
message: "extension uses a browser runtime; Runway mobile provisioning is not supported.",
|
|
40
|
+
userAction: options.rerunCommand || "mm-harness provision --adapter extension"
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
devServer: {
|
|
46
|
+
describe: () => "webpack watcher",
|
|
47
|
+
stop(target) {
|
|
48
|
+
const signalled = stopExtensionWatcher(target);
|
|
49
|
+
const port = process.env.WATCHER_PORT ?? "default";
|
|
50
|
+
spawnSync("tmux", ["kill-window", "-t", `webpack-${port}`], { stdio: "ignore", timeout: 2e3 });
|
|
51
|
+
const summary = signalled > 0 ? `stopped webpack watcher (${signalled} process${signalled === 1 ? "" : "es"}) for ${target}` : `webpack watcher not running for ${target} \u2014 nothing to stop`;
|
|
52
|
+
return { kind: "stopped", status: 0, summary, signalled };
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
logSources(target) {
|
|
56
|
+
return [
|
|
57
|
+
{ label: "webpack", path: recipeRuntimePath(target, "webpack.log") },
|
|
58
|
+
{ label: "watcher", path: recipeRuntimePath(target, "recipe-harness-webpack.log") },
|
|
59
|
+
{ label: "rebuild", path: recipeRuntimePath(target, "rebuild.log") }
|
|
60
|
+
];
|
|
61
|
+
},
|
|
62
|
+
hints: {
|
|
63
|
+
launch: "mm-harness launch",
|
|
64
|
+
relaunch: "mm-harness launch --build"
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
export {
|
|
68
|
+
extensionSurface
|
|
69
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
const MOBILE_PRODUCT_MARKERS = [
|
|
2
|
+
"node_modules/.yarn-state.yml",
|
|
3
|
+
"app/core/InpageBridgeWeb3.js",
|
|
4
|
+
"docs/assets/termsOfUse.html",
|
|
5
|
+
"app/util/termsOfUse/termsOfUseContent.ts",
|
|
6
|
+
"node_modules/.bin/anvil"
|
|
7
|
+
];
|
|
8
|
+
const MOBILE_IOS_NATIVE_MARKERS = ["ios/Podfile.lock"];
|
|
9
|
+
const MOBILE_ANDROID_NATIVE_MARKERS = ["android/gradle.properties"];
|
|
10
|
+
function mobileProductMarkers(platform) {
|
|
11
|
+
const markers = [...MOBILE_PRODUCT_MARKERS];
|
|
12
|
+
const resolved = (platform ?? process.env.PLATFORM ?? process.env.RECIPE_HARNESS_PLATFORM ?? "").trim().toLowerCase();
|
|
13
|
+
if (resolved === "ios") markers.push(...MOBILE_IOS_NATIVE_MARKERS);
|
|
14
|
+
else if (resolved === "android") markers.push(...MOBILE_ANDROID_NATIVE_MARKERS);
|
|
15
|
+
return markers;
|
|
16
|
+
}
|
|
17
|
+
export {
|
|
18
|
+
MOBILE_ANDROID_NATIVE_MARKERS,
|
|
19
|
+
MOBILE_IOS_NATIVE_MARKERS,
|
|
20
|
+
MOBILE_PRODUCT_MARKERS,
|
|
21
|
+
mobileProductMarkers
|
|
22
|
+
};
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { recordDepsBaseline } from "@farmslot/recipe-harness/runtime/deps-readiness";
|
|
3
|
+
import { EXIT, spawnScriptStreaming } from "../../commands/shared.js";
|
|
4
|
+
import { runnerDir } from "../../paths.js";
|
|
5
|
+
import {
|
|
6
|
+
decideMobileReadiness
|
|
7
|
+
} from "./runtime-decision.js";
|
|
8
|
+
const POD_PROBE_ENV = {
|
|
9
|
+
FORCE_COLOR: "0",
|
|
10
|
+
NO_COLOR: "1",
|
|
11
|
+
LANG: process.env.LANG?.includes("UTF-8") ? process.env.LANG : "en_US.UTF-8",
|
|
12
|
+
LC_ALL: process.env.LC_ALL?.includes("UTF-8") ? process.env.LC_ALL : "en_US.UTF-8"
|
|
13
|
+
};
|
|
14
|
+
async function mobileRuntimeStatus(target, opts = {}) {
|
|
15
|
+
return decideMobileReadiness(target, {
|
|
16
|
+
watcherPort: opts.watcherPort,
|
|
17
|
+
metroLog: opts.metroLog,
|
|
18
|
+
platform: opts.platform,
|
|
19
|
+
record: opts.record,
|
|
20
|
+
preflightMode: opts.preflightMode
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
async function prepareMobile(target, opts = {}) {
|
|
24
|
+
const json = opts.json ?? false;
|
|
25
|
+
const platform = opts.platform ?? "ios";
|
|
26
|
+
const preflightMode = opts.preflightMode ?? "fast";
|
|
27
|
+
const clearMetro = opts.clearMetro ?? false;
|
|
28
|
+
const report = await decideMobileReadiness(target, {
|
|
29
|
+
watcherPort: opts.watcherPort,
|
|
30
|
+
metroLog: opts.metroLog,
|
|
31
|
+
platform,
|
|
32
|
+
record: opts.record,
|
|
33
|
+
preflightMode
|
|
34
|
+
});
|
|
35
|
+
if (report.decision === "blocked") {
|
|
36
|
+
const reasons = report.reasons.join(" ");
|
|
37
|
+
const next = report.userAction ?? "fix the bundle error in app code before retrying recipe up.";
|
|
38
|
+
const msg = `mobile prepare blocked: ${reasons}
|
|
39
|
+
Next: ${next}`;
|
|
40
|
+
if (!json) process.stderr.write(`${msg}
|
|
41
|
+
`);
|
|
42
|
+
return { status: EXIT.runtime, output: msg };
|
|
43
|
+
}
|
|
44
|
+
if (report.decision === "ready") {
|
|
45
|
+
return { status: 0, output: "" };
|
|
46
|
+
}
|
|
47
|
+
if (report.decision === "unknown") {
|
|
48
|
+
const msg = "mobile prepare: runtime state unknown\n Next: run mm-harness verify --adapter mobile --target <checkout>";
|
|
49
|
+
if (!json) process.stderr.write(`${msg}
|
|
50
|
+
`);
|
|
51
|
+
return { status: EXIT.runtime, output: msg };
|
|
52
|
+
}
|
|
53
|
+
const actions = clearMetro ? report.actions.map(
|
|
54
|
+
(a) => a.id === "start-metro" && !a.argv?.includes("--clear") ? { ...a, argv: [...a.argv ?? [], "--clear"] } : a
|
|
55
|
+
) : report.actions;
|
|
56
|
+
for (const action of actions) {
|
|
57
|
+
const result = await dispatchAction(action, target, platform, json, preflightMode);
|
|
58
|
+
if (result.status !== 0) return result;
|
|
59
|
+
if (action.id === "yarn-setup") recordDepsBaseline(path.resolve(target));
|
|
60
|
+
}
|
|
61
|
+
if (report.decision === "install" && !process.env["RECIPE_UP_INSTALL_ATTEMPTED"]) {
|
|
62
|
+
process.env["RECIPE_UP_INSTALL_ATTEMPTED"] = "1";
|
|
63
|
+
const postInstall = await decideMobileReadiness(target, {
|
|
64
|
+
watcherPort: opts.watcherPort,
|
|
65
|
+
metroLog: opts.metroLog,
|
|
66
|
+
platform,
|
|
67
|
+
preflightMode
|
|
68
|
+
});
|
|
69
|
+
switch (postInstall.decision) {
|
|
70
|
+
case "install": {
|
|
71
|
+
const msg = `mobile prepare: dependencies installed but runtime is still unresolved (${postInstall.reasons.join("; ")})
|
|
72
|
+
Next: inspect the checkout \u2014 node_modules may be incomplete or yarn.lock drifted.`;
|
|
73
|
+
if (!json) process.stderr.write(`${msg}
|
|
74
|
+
`);
|
|
75
|
+
return { status: EXIT.runtime, output: msg };
|
|
76
|
+
}
|
|
77
|
+
case "ready": {
|
|
78
|
+
const bridge = await dispatchAction({ id: "wait-for-bridge", cwd: target }, target, platform, json, preflightMode);
|
|
79
|
+
if (bridge.status !== 0) return bridge;
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
case "launch": {
|
|
83
|
+
for (const action of postInstall.actions) {
|
|
84
|
+
const result = await dispatchAction(action, target, platform, json, preflightMode);
|
|
85
|
+
if (result.status !== 0) return result;
|
|
86
|
+
}
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
default: {
|
|
90
|
+
const msg = `mobile prepare: post-install state is ${postInstall.decision}: ${postInstall.reasons.join("; ")}
|
|
91
|
+
Next: run mm-harness verify --adapter mobile --target <checkout>`;
|
|
92
|
+
if (!json) process.stderr.write(`${msg}
|
|
93
|
+
`);
|
|
94
|
+
return { status: EXIT.runtime, output: msg };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return { status: 0, output: "" };
|
|
99
|
+
}
|
|
100
|
+
async function dispatchAction(action, target, platform, json, preflightMode = "fast") {
|
|
101
|
+
const cwd = action.cwd ?? target;
|
|
102
|
+
switch (action.id) {
|
|
103
|
+
case "yarn-setup": {
|
|
104
|
+
const leaf = path.join(runnerDir, "adapters/mobile/yarn-setup.sh");
|
|
105
|
+
return spawnScriptStreaming(leaf, ["--target", cwd], target, POD_PROBE_ENV);
|
|
106
|
+
}
|
|
107
|
+
case "start-metro": {
|
|
108
|
+
const leaf = path.join(runnerDir, "adapters/mobile/start-metro.sh");
|
|
109
|
+
const extra = action.argv ?? [];
|
|
110
|
+
return spawnScriptStreaming(leaf, ["--target", cwd, ...extra], target);
|
|
111
|
+
}
|
|
112
|
+
case "prewarm-bundle": {
|
|
113
|
+
const leaf = path.join(runnerDir, "adapters/mobile/prewarm-bundle.sh");
|
|
114
|
+
return spawnScriptStreaming(leaf, ["--platform", platform, "--target", cwd], target);
|
|
115
|
+
}
|
|
116
|
+
case "wait-for-bridge": {
|
|
117
|
+
const leaf = path.join(runnerDir, "adapters/mobile/wait-for-bridge.sh");
|
|
118
|
+
return spawnScriptStreaming(leaf, ["--target", cwd], target);
|
|
119
|
+
}
|
|
120
|
+
case "clear-metro-cache": {
|
|
121
|
+
const leaf = path.join(runnerDir, "adapters/mobile/start-metro.sh");
|
|
122
|
+
return spawnScriptStreaming(leaf, ["--target", cwd, "--clear"], target);
|
|
123
|
+
}
|
|
124
|
+
case "launch-mobile-runtime": {
|
|
125
|
+
const leaf = path.join(runnerDir, "adapters/mobile/open-device.sh");
|
|
126
|
+
return spawnScriptStreaming(
|
|
127
|
+
leaf,
|
|
128
|
+
["--platform", platform, "--target", cwd, "--preflight-mode", preflightMode],
|
|
129
|
+
target,
|
|
130
|
+
POD_PROBE_ENV
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
case "rebuild-native-dev-client": {
|
|
134
|
+
const msg = "rebuild-native-dev-client required: native module mismatch detected.\n Next: yarn start:ios or yarn start:android to rebuild the native dev client, then re-run mm-harness launch.";
|
|
135
|
+
if (!json) process.stderr.write(`${msg}
|
|
136
|
+
`);
|
|
137
|
+
return { status: EXIT.runtime, output: msg };
|
|
138
|
+
}
|
|
139
|
+
default:
|
|
140
|
+
return { status: 0, output: "" };
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
export {
|
|
144
|
+
mobileRuntimeStatus,
|
|
145
|
+
prepareMobile
|
|
146
|
+
};
|