@deeeed/metamask-harness 0.40.1 → 0.41.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/adapters/manifest.json +25 -1
- package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +5 -4
- package/adapters/mobile/bridge-runtime/lib/target-discovery.cjs +24 -5
- package/adapters/mobile/coalesce-metro-log.cjs +24 -0
- package/adapters/mobile/launch-metro.cjs +9 -8
- package/adapters/mobile/metro-config.cjs +94 -0
- package/adapters/mobile/metro-log-generation.cjs +106 -0
- package/adapters/mobile/start-metro.sh +30 -8
- package/adapters/mobile/stop-metro.sh +5 -6
- package/adapters/shared/reap-checkout-metros.sh +17 -0
- package/dist/adapters/mobile/runtime-decision.js +0 -21
- package/dist/commands/device-target.js +3 -0
- package/dist/commands/launch/index.js +25 -5
- package/dist/commands/run-engine.js +4 -14
- package/dist/heal-bounds.js +1 -0
- package/dist/live-adapter-contract.js +2 -1
- package/dist/mm-harness-cli.js +1 -1
- package/docs/QA.md +2 -0
- package/docs/RECIPES.md +25 -19
- package/library/actions/mobile/perps/capture_performance.mjs +2 -2
- package/library/actions/mobile/perps/performance-capture.mjs +375 -17
- package/library/actions/mobile/perps/perps.mjs +56 -10
- package/library/actions/mobile/platform/bridge.mjs +59 -1
- package/library/actions/mobile/wallet/ensure_unlocked.mjs +60 -26
- package/library/actions/mobile/wallet/import.mjs +2 -0
- package/library/actions/mobile/wallet/select_account.mjs +21 -2
- package/library/manifests/mobile.action-manifest.json +47 -6
- package/library/recipes/mobile/perps/performance.recipe.json +680 -26
- package/package.json +1 -1
- package/library/recipes/mobile/perps/performance.background-resume.recipe.json +0 -56
- package/library/recipes/mobile/perps/performance.cold-start.recipe.json +0 -56
- package/library/recipes/mobile/perps/performance.homepage.android-background-reconnect.recipe.json +0 -159
- package/library/recipes/mobile/perps/performance.homepage.android-background-short.recipe.json +0 -157
- package/library/recipes/mobile/perps/performance.homepage.android-cold-disk-cache.recipe.json +0 -165
- package/library/recipes/mobile/perps/performance.homepage.android-cold-no-cache.recipe.json +0 -139
- package/library/recipes/mobile/perps/performance.homepage.android-network-recovery.recipe.json +0 -149
- package/library/recipes/mobile/perps/performance.homepage.cold-position-sample.recipe.json +0 -120
- package/library/recipes/mobile/perps/performance.homepage.ios-background-reconnect.recipe.json +0 -111
- package/library/recipes/mobile/perps/performance.homepage.ios-background-short.recipe.json +0 -108
- package/library/recipes/mobile/perps/performance.homepage.ios-cold-disk-cache.recipe.json +0 -122
- package/library/recipes/mobile/perps/performance.homepage.ios-cold-no-cache.recipe.json +0 -95
- package/library/recipes/mobile/perps/performance.warm-start.recipe.json +0 -49
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,28 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.41.1 - 2026-08-19
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Rotate each harness-owned Metro start into a run-scoped evidence generation, bound retained archives, coalesce repetitive bundle progress, and keep bundle-error recovery state inside one launch invocation.
|
|
10
|
+
|
|
11
|
+
## 0.41.0 - 2026-08-19
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- Add one parameterized, platform-neutral Mobile Perps performance recipe covering cold, cached, resume, reconnect, account-switch, and network-switch lifecycles with native visible-content proof and production loading/WebSocket evidence.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- Replace the duplicated Android, iOS, cold, warm, and background Perps performance recipes with the single lifecycle recipe.
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- Keep explicitly pinned iOS and Android bridge targets isolated when both platforms are active, and classify bounded CDP target failures as infrastructure failures.
|
|
24
|
+
- Make Mobile wallet unlock and account selection deterministic across app restarts, propagate recipe timeouts through Perps state setup, and preserve missing performance counts and offsets as unknown instead of zero.
|
|
25
|
+
- Keep the entire checkout-local `temp` tree outside Metro watching during canonical `yarn watch` launches, and leave the Mobile recipe HUD visible by default for performance lifecycles.
|
|
26
|
+
|
|
5
27
|
## 0.40.1 - 2026-08-16
|
|
6
28
|
|
|
7
29
|
### Fixed
|
package/adapters/manifest.json
CHANGED
|
@@ -39,9 +39,33 @@
|
|
|
39
39
|
"entry": "adapters/mobile/launch-metro.cjs",
|
|
40
40
|
"kind": "node",
|
|
41
41
|
"purpose": "Launch Metro detached from the invoking process group so the runtime survives command cleanup.",
|
|
42
|
-
"inputs": "--target --port --log --pid-file [--workers] [--clear]",
|
|
42
|
+
"inputs": "--target --port --log --pid-file --build-env --runner [--workers] [--clear]",
|
|
43
43
|
"outputs": "Metro PID/launch metadata and redirected log; exit 0/1/2"
|
|
44
44
|
},
|
|
45
|
+
{
|
|
46
|
+
"id": "mobile/metro-log-generation",
|
|
47
|
+
"entry": "adapters/mobile/metro-log-generation.cjs",
|
|
48
|
+
"kind": "module",
|
|
49
|
+
"purpose": "Rotate one Metro generation, retain bounded archives, and publish launch evidence.",
|
|
50
|
+
"inputs": "runtime directory, Metro port, rotation reason; env MM_HARNESS_METRO_LOG_ARCHIVE_COUNT, MM_HARNESS_METRO_LOG_ARCHIVE_BYTES",
|
|
51
|
+
"outputs": "metro.log, archived logs, metro-generation.json"
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"id": "mobile/coalesce-metro-log",
|
|
55
|
+
"entry": "adapters/mobile/coalesce-metro-log.cjs",
|
|
56
|
+
"kind": "module",
|
|
57
|
+
"purpose": "Coalesce repetitive Metro percentage progress while preserving diagnostics.",
|
|
58
|
+
"inputs": "Metro output on stdin",
|
|
59
|
+
"outputs": "errors, completion, and timestamped meaningful progress on stdout"
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
"id": "mobile/metro-config",
|
|
63
|
+
"entry": "adapters/mobile/metro-config.cjs",
|
|
64
|
+
"kind": "module",
|
|
65
|
+
"purpose": "Preserve the product Metro config while excluding the checkout-local temp tree owned by harness evidence and runtime state.",
|
|
66
|
+
"inputs": "base Metro config; env MM_HARNESS_METRO_PROJECT_ROOT, MM_HARNESS_METRO_BASE_CONFIG",
|
|
67
|
+
"outputs": "merged Metro config with resolver.blockList for <projectRoot>/temp"
|
|
68
|
+
},
|
|
45
69
|
{
|
|
46
70
|
"id": "mobile/stop-metro",
|
|
47
71
|
"entry": "adapters/mobile/stop-metro.sh",
|
|
@@ -30,12 +30,13 @@ const {
|
|
|
30
30
|
const { cdpEval, cdpEvalAsync } = require('./lib/cdp-eval.cjs');
|
|
31
31
|
const { buildArmSnippet, buildCollectSnippet } = require('./lib/issue-capture.cjs');
|
|
32
32
|
|
|
33
|
-
function
|
|
34
|
-
const
|
|
33
|
+
function parsePerformanceConsoleEvent(params) {
|
|
34
|
+
const markers = ['[PerpsPerf] ', '[HomepagePerf] '];
|
|
35
35
|
for (const arg of params?.args || []) {
|
|
36
36
|
const text = typeof arg?.value === 'string' ? arg.value : '';
|
|
37
|
+
const marker = markers.find((candidate) => text.includes(candidate));
|
|
38
|
+
if (!marker) continue;
|
|
37
39
|
const markerIndex = text.indexOf(marker);
|
|
38
|
-
if (markerIndex < 0) continue;
|
|
39
40
|
try {
|
|
40
41
|
return JSON.parse(text.slice(markerIndex + marker.length));
|
|
41
42
|
} catch {}
|
|
@@ -490,7 +491,7 @@ const COMMANDS = {
|
|
|
490
491
|
let removeConsoleListener = null;
|
|
491
492
|
if (visibleEventStage) {
|
|
492
493
|
removeConsoleListener = client.on('Runtime.consoleAPICalled', (params) => {
|
|
493
|
-
const payload =
|
|
494
|
+
const payload = parsePerformanceConsoleEvent(params);
|
|
494
495
|
if (payload?.stage === visibleEventStage) visibleEvents.push(payload);
|
|
495
496
|
});
|
|
496
497
|
await client.send('Runtime.enable');
|
|
@@ -145,6 +145,9 @@ async function discoverTarget(port) {
|
|
|
145
145
|
const adbSerial = process.env.ADB_SERIAL || process.env.ANDROID_SERIAL || '';
|
|
146
146
|
const androidPinned = Boolean(androidTargetName || androidDevice);
|
|
147
147
|
const simName = loadSimulatorName();
|
|
148
|
+
const iosPinned =
|
|
149
|
+
process.env.MM_HARNESS_EXPLICIT_PLATFORM === 'ios' ||
|
|
150
|
+
Boolean(process.env.SIM_UDID);
|
|
148
151
|
const matchesAndroidPin = (target) => androidTargetName
|
|
149
152
|
? target.deviceName !== simName &&
|
|
150
153
|
(target.deviceName === androidTargetName ||
|
|
@@ -166,6 +169,18 @@ async function discoverTarget(port) {
|
|
|
166
169
|
}
|
|
167
170
|
return false;
|
|
168
171
|
}
|
|
172
|
+
if (iosPinned && simName) {
|
|
173
|
+
const pinnedCandidates = runtimeCandidates.filter(
|
|
174
|
+
(candidate) => candidate.deviceName === simName,
|
|
175
|
+
);
|
|
176
|
+
for (const candidate of pinnedCandidates) {
|
|
177
|
+
if (await probeTarget(candidate.webSocketDebuggerUrl)) {
|
|
178
|
+
acceptedPinnedCandidate = candidate;
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
169
184
|
return true;
|
|
170
185
|
});
|
|
171
186
|
} catch (e) {
|
|
@@ -193,12 +208,16 @@ async function discoverTarget(port) {
|
|
|
193
208
|
// context, and letting it win here sends a pinned android action to the iOS
|
|
194
209
|
// target. No-match keeps the full candidate set (ambient sim configs tolerate a
|
|
195
210
|
// sim that is not currently attached).
|
|
196
|
-
if (simName && !androidPinned
|
|
211
|
+
if (simName && !androidPinned) {
|
|
197
212
|
const deviceFiltered = candidates.filter(
|
|
198
213
|
(t) => t.deviceName === simName,
|
|
199
214
|
);
|
|
200
215
|
if (deviceFiltered.length > 0) {
|
|
201
216
|
candidates = deviceFiltered;
|
|
217
|
+
} else if (iosPinned) {
|
|
218
|
+
throw coded(new Error(
|
|
219
|
+
`Pinned iOS simulator '${simName}' did not match any Metro target.`,
|
|
220
|
+
), BRIDGE_ERROR_CODES.NO_TARGET);
|
|
202
221
|
}
|
|
203
222
|
}
|
|
204
223
|
|
|
@@ -306,15 +325,15 @@ async function discoverTarget(port) {
|
|
|
306
325
|
}
|
|
307
326
|
}
|
|
308
327
|
|
|
309
|
-
if (androidPinned) {
|
|
328
|
+
if (androidPinned || iosPinned) {
|
|
310
329
|
const candidateList = candidates
|
|
311
330
|
.map((target) => ` deviceName=${JSON.stringify(target.deviceName || '')} ws=${target.webSocketDebuggerUrl || ''}`)
|
|
312
331
|
.join('\n');
|
|
313
332
|
throw coded(new Error(
|
|
314
|
-
`Pinned Android device has no responding __AGENTIC__ JS runtime after ${FETCH_RETRIES} discovery attempt(s).\n` +
|
|
315
|
-
` Requested --device (ADB_SERIAL): ${adbSerial || '(not set)'}\n` +
|
|
333
|
+
`Pinned ${androidPinned ? 'Android device' : `iOS simulator '${simName || process.env.SIM_UDID || '(unknown)'}'`} has no responding __AGENTIC__ JS runtime after ${FETCH_RETRIES} discovery attempt(s).\n` +
|
|
334
|
+
(androidPinned ? ` Requested --device (ADB_SERIAL): ${adbSerial || '(not set)'}\n` : '') +
|
|
316
335
|
` Matching non-agentic targets:\n${candidateList}\n` +
|
|
317
|
-
` The native C++ page is not a valid recipe target; wait for
|
|
336
|
+
` The native C++ page is not a valid recipe target; wait for Hermes to re-register or relaunch the pinned app.`,
|
|
318
337
|
), BRIDGE_ERROR_CODES.NO_TARGET);
|
|
319
338
|
}
|
|
320
339
|
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const readline = require('node:readline');
|
|
5
|
+
|
|
6
|
+
const progressPattern = /^\s*(?:iOS|Android).*?(\d{1,3}(?:\.\d+)?)%/u;
|
|
7
|
+
let lastPercent = null;
|
|
8
|
+
let lastProgressAt = 0;
|
|
9
|
+
|
|
10
|
+
const lines = readline.createInterface({ input: process.stdin, crlfDelay: Infinity, terminal: false });
|
|
11
|
+
lines.on('line', (line) => {
|
|
12
|
+
const progress = progressPattern.exec(line);
|
|
13
|
+
if (!progress) {
|
|
14
|
+
process.stdout.write(`${line}\n`);
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
const percent = Math.floor(Number(progress[1]));
|
|
18
|
+
const now = Date.now();
|
|
19
|
+
if (percent !== lastPercent || now - lastProgressAt >= 15_000) {
|
|
20
|
+
process.stdout.write(`${line} [metro-progress ${new Date(now).toISOString()}]\n`);
|
|
21
|
+
lastPercent = percent;
|
|
22
|
+
lastProgressAt = now;
|
|
23
|
+
}
|
|
24
|
+
});
|
|
@@ -26,33 +26,34 @@ function parseArgs(argv) {
|
|
|
26
26
|
async function main() {
|
|
27
27
|
const args = parseArgs(process.argv.slice(2));
|
|
28
28
|
if (args.help) {
|
|
29
|
-
console.log('Usage: launch-metro.cjs --target <path> --port <port> --log <path> --pid-file <path> --build-env <path> [--workers <n>] [--clear]');
|
|
29
|
+
console.log('Usage: launch-metro.cjs --target <path> --port <port> --log <path> --pid-file <path> --build-env <path> --runner <path> [--workers <n>] [--clear]');
|
|
30
30
|
return;
|
|
31
31
|
}
|
|
32
|
-
for (const required of ['target', 'port', 'log', 'pid-file', 'build-env']) {
|
|
32
|
+
for (const required of ['target', 'port', 'log', 'pid-file', 'build-env', 'runner']) {
|
|
33
33
|
if (!args[required]) throw new Error(`--${required} is required`);
|
|
34
34
|
}
|
|
35
35
|
const target = path.resolve(args.target);
|
|
36
36
|
const log = path.resolve(args.log);
|
|
37
37
|
const pidFile = path.resolve(args['pid-file']);
|
|
38
|
+
const runner = path.resolve(args.runner);
|
|
38
39
|
fs.mkdirSync(path.dirname(log), { recursive: true });
|
|
39
40
|
fs.mkdirSync(path.dirname(pidFile), { recursive: true });
|
|
40
|
-
const
|
|
41
|
-
|
|
41
|
+
const runnerStat = fs.lstatSync(runner);
|
|
42
|
+
if (!runnerStat.isFile()) throw new Error('--runner must be a regular file');
|
|
43
|
+
fs.accessSync(runner, fs.constants.X_OK);
|
|
42
44
|
const env = { ...process.env };
|
|
43
45
|
env.BASH_ENV = args['build-env'];
|
|
44
46
|
if (args.workers) env.METRO_MAX_WORKERS = String(args.workers);
|
|
45
|
-
const child = spawn(
|
|
47
|
+
const child = spawn(runner, [], {
|
|
46
48
|
cwd: target,
|
|
47
49
|
detached: true,
|
|
48
50
|
env,
|
|
49
|
-
stdio:
|
|
51
|
+
stdio: 'ignore',
|
|
50
52
|
});
|
|
51
53
|
await new Promise((resolve, reject) => {
|
|
52
54
|
child.once('spawn', resolve);
|
|
53
55
|
child.once('error', reject);
|
|
54
56
|
});
|
|
55
|
-
fs.closeSync(logFd);
|
|
56
57
|
fs.writeFileSync(pidFile, `${String(child.pid)}\n`);
|
|
57
58
|
fs.writeFileSync(
|
|
58
59
|
path.join(path.dirname(pidFile), 'metro-launch.json'),
|
|
@@ -64,7 +65,7 @@ async function main() {
|
|
|
64
65
|
target,
|
|
65
66
|
port: Number(args.port),
|
|
66
67
|
clear: Boolean(args.clear),
|
|
67
|
-
command:
|
|
68
|
+
command: runner,
|
|
68
69
|
}, null, 2)}\n`,
|
|
69
70
|
);
|
|
70
71
|
child.unref();
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const { createRequire } = require('node:module');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const { pathToFileURL } = require('node:url');
|
|
7
|
+
|
|
8
|
+
function escapeRegExp(value) {
|
|
9
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function appendBlockList(blockList, pattern) {
|
|
13
|
+
if (Array.isArray(blockList)) return [...blockList, pattern];
|
|
14
|
+
return blockList instanceof RegExp ? [blockList, pattern] : [pattern];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function blockListFlags(blockList) {
|
|
18
|
+
const first = Array.isArray(blockList) ? blockList[0] : blockList;
|
|
19
|
+
return first instanceof RegExp && first.ignoreCase ? 'i' : '';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function resolveExpoConfigLoader(projectRoot) {
|
|
23
|
+
const requireFromProject = createRequire(path.join(projectRoot, 'package.json'));
|
|
24
|
+
let requireUtilsPath;
|
|
25
|
+
try {
|
|
26
|
+
requireUtilsPath = requireFromProject.resolve('@expo/require-utils');
|
|
27
|
+
} catch (error) {
|
|
28
|
+
if (error?.code !== 'MODULE_NOT_FOUND') throw error;
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
const { loadModuleSync } = requireFromProject(requireUtilsPath);
|
|
32
|
+
return typeof loadModuleSync === 'function' ? loadModuleSync : null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function loadProductConfig(configPath, baseConfig, projectRoot) {
|
|
36
|
+
let loaded;
|
|
37
|
+
const expoConfigLoader = resolveExpoConfigLoader(projectRoot);
|
|
38
|
+
if (expoConfigLoader) {
|
|
39
|
+
loaded = expoConfigLoader(configPath);
|
|
40
|
+
} else {
|
|
41
|
+
try {
|
|
42
|
+
loaded = require(configPath);
|
|
43
|
+
} catch (error) {
|
|
44
|
+
if (error?.code !== 'ERR_REQUIRE_ESM') throw error;
|
|
45
|
+
loaded = await import(pathToFileURL(configPath).href);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const moduleValue = await loaded;
|
|
50
|
+
const exported =
|
|
51
|
+
moduleValue?.__esModule || moduleValue?.[Symbol.toStringTag] === 'Module'
|
|
52
|
+
? moduleValue.default
|
|
53
|
+
: moduleValue;
|
|
54
|
+
const config = await exported;
|
|
55
|
+
return typeof config === 'function' ? config(baseConfig) : config;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
module.exports = async function harnessMetroConfig(baseConfig) {
|
|
59
|
+
const projectRoot = path.resolve(
|
|
60
|
+
process.env.MM_HARNESS_METRO_PROJECT_ROOT || process.cwd(),
|
|
61
|
+
);
|
|
62
|
+
const productConfigPath = path.resolve(
|
|
63
|
+
process.env.MM_HARNESS_METRO_BASE_CONFIG ||
|
|
64
|
+
path.join(projectRoot, 'metro.config.js'),
|
|
65
|
+
);
|
|
66
|
+
if (productConfigPath === __filename) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
'MM_HARNESS_METRO_BASE_CONFIG must reference the product Metro config, not the harness wrapper.',
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
let productConfig = baseConfig;
|
|
72
|
+
if (fs.existsSync(productConfigPath)) {
|
|
73
|
+
productConfig = await loadProductConfig(
|
|
74
|
+
productConfigPath,
|
|
75
|
+
baseConfig,
|
|
76
|
+
projectRoot,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const tempRoot = path.join(projectRoot, 'temp');
|
|
81
|
+
const existingBlockList =
|
|
82
|
+
productConfig.resolver?.blockList ?? baseConfig.resolver?.blockList;
|
|
83
|
+
const tempPattern = new RegExp(
|
|
84
|
+
`^${escapeRegExp(tempRoot)}(?:${escapeRegExp(path.sep)}|$)`,
|
|
85
|
+
blockListFlags(existingBlockList),
|
|
86
|
+
);
|
|
87
|
+
return {
|
|
88
|
+
...productConfig,
|
|
89
|
+
resolver: {
|
|
90
|
+
...productConfig.resolver,
|
|
91
|
+
blockList: appendBlockList(existingBlockList, tempPattern),
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
};
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const crypto = require('node:crypto');
|
|
5
|
+
const fs = require('node:fs');
|
|
6
|
+
const path = require('node:path');
|
|
7
|
+
|
|
8
|
+
const generationIdPattern = /^[A-Za-z0-9-]+$/u;
|
|
9
|
+
|
|
10
|
+
function archiveGenerationId(name) {
|
|
11
|
+
if (!name.startsWith('metro.') || !name.endsWith('.log')) return null;
|
|
12
|
+
const generationId = name.slice('metro.'.length, -'.log'.length);
|
|
13
|
+
return generationIdPattern.test(generationId) ? generationId : null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function positiveInteger(name, fallback) {
|
|
17
|
+
const raw = process.env[name];
|
|
18
|
+
if (raw === undefined || raw === '') return fallback;
|
|
19
|
+
if (!/^[1-9][0-9]*$/u.test(raw)) throw new Error(`${name} must be a positive integer`);
|
|
20
|
+
return Number(raw);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function main() {
|
|
24
|
+
const runtimeArg = process.argv[2];
|
|
25
|
+
const port = process.argv[3];
|
|
26
|
+
const reason = process.argv[4];
|
|
27
|
+
if (!runtimeArg || !/^[1-9][0-9]*$/u.test(port ?? '') || !reason) {
|
|
28
|
+
throw new Error('usage: metro-log-generation.cjs <runtime-dir> <port> <reason>');
|
|
29
|
+
}
|
|
30
|
+
const runtimeDir = fs.realpathSync(runtimeArg);
|
|
31
|
+
const activeLog = path.join(runtimeDir, 'metro.log');
|
|
32
|
+
const evidenceFile = path.join(runtimeDir, 'metro-generation.json');
|
|
33
|
+
const maxArchives = positiveInteger('MM_HARNESS_METRO_LOG_ARCHIVE_COUNT', 4);
|
|
34
|
+
const maxArchiveBytes = positiveInteger('MM_HARNESS_METRO_LOG_ARCHIVE_BYTES', 8 * 1024 * 1024);
|
|
35
|
+
const generationId = `${new Date().toISOString().replace(/[-:.]/gu, '')}-${port}-${process.pid}-${crypto.randomBytes(4).toString('hex')}`;
|
|
36
|
+
if (!generationIdPattern.test(generationId)) throw new Error('generated Metro log identity is invalid');
|
|
37
|
+
let rotatedLog = null;
|
|
38
|
+
|
|
39
|
+
rotatedLog = path.join(runtimeDir, `metro.${generationId}.log`);
|
|
40
|
+
try {
|
|
41
|
+
fs.renameSync(activeLog, rotatedLog);
|
|
42
|
+
const stat = fs.lstatSync(rotatedLog);
|
|
43
|
+
if (!stat.isFile()) {
|
|
44
|
+
fs.renameSync(rotatedLog, activeLog);
|
|
45
|
+
throw new Error('metro.log must be a regular file');
|
|
46
|
+
}
|
|
47
|
+
} catch (error) {
|
|
48
|
+
if (error && error.code === 'ENOENT') rotatedLog = null;
|
|
49
|
+
else throw error;
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
fs.closeSync(fs.openSync(activeLog, 'wx', 0o600));
|
|
53
|
+
} catch (error) {
|
|
54
|
+
if (error && error.code === 'EEXIST') throw new Error('another Metro generation recreated metro.log; refusing to truncate it');
|
|
55
|
+
throw error;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const archives = fs.readdirSync(runtimeDir)
|
|
59
|
+
.filter((name) => archiveGenerationId(name) !== null)
|
|
60
|
+
.map((name) => {
|
|
61
|
+
const file = path.join(runtimeDir, name);
|
|
62
|
+
const stat = fs.lstatSync(file);
|
|
63
|
+
if (!stat.isFile()) return null;
|
|
64
|
+
return { file, size: stat.size, mtimeMs: stat.mtimeMs };
|
|
65
|
+
})
|
|
66
|
+
.filter(Boolean)
|
|
67
|
+
.sort((left, right) => right.mtimeMs - left.mtimeMs || right.file.localeCompare(left.file));
|
|
68
|
+
|
|
69
|
+
let retainedBytes = 0;
|
|
70
|
+
let retainedCount = 0;
|
|
71
|
+
const retained = [];
|
|
72
|
+
const removed = [];
|
|
73
|
+
for (const archive of archives) {
|
|
74
|
+
if (retainedCount < maxArchives && retainedBytes + archive.size <= maxArchiveBytes) {
|
|
75
|
+
retained.push(archive.file);
|
|
76
|
+
retainedCount += 1;
|
|
77
|
+
retainedBytes += archive.size;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
fs.unlinkSync(archive.file);
|
|
81
|
+
removed.push({ path: archive.file, size: archive.size, reason: retainedCount >= maxArchives ? 'count-limit' : 'size-limit' });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const evidence = {
|
|
85
|
+
schemaVersion: 1,
|
|
86
|
+
generationId,
|
|
87
|
+
port: Number(port),
|
|
88
|
+
startedAt: new Date().toISOString(),
|
|
89
|
+
currentLog: activeLog,
|
|
90
|
+
rotatedLog: rotatedLog && fs.existsSync(rotatedLog) ? rotatedLog : null,
|
|
91
|
+
archivedLogs: retained,
|
|
92
|
+
rotationReason: reason,
|
|
93
|
+
retention: { maxArchives, maxArchiveBytes, retainedBytes, removed },
|
|
94
|
+
};
|
|
95
|
+
const temporary = `${evidenceFile}.${process.pid}.tmp`;
|
|
96
|
+
fs.writeFileSync(temporary, `${JSON.stringify(evidence, null, 2)}\n`, { mode: 0o600, flag: 'wx' });
|
|
97
|
+
fs.renameSync(temporary, evidenceFile);
|
|
98
|
+
process.stdout.write(`${JSON.stringify(evidence)}\n`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
main();
|
|
103
|
+
} catch (error) {
|
|
104
|
+
console.error(`metro-log-generation: ${error instanceof Error ? error.message : String(error)}`);
|
|
105
|
+
process.exit(1);
|
|
106
|
+
}
|
|
@@ -50,7 +50,16 @@ fi
|
|
|
50
50
|
# only ever inspect or signal a Metro listening on $PORT.
|
|
51
51
|
# shellcheck disable=SC1091
|
|
52
52
|
. "$SCRIPT_DIR/lib/metro-listener.sh"
|
|
53
|
-
|
|
53
|
+
HARNESS_METRO_CONFIG="$SCRIPT_DIR/metro-config.cjs"
|
|
54
|
+
if [ "${EXPO_OVERRIDE_METRO_CONFIG:-}" = "$HARNESS_METRO_CONFIG" ]; then
|
|
55
|
+
PRODUCT_METRO_CONFIG="${MM_HARNESS_METRO_BASE_CONFIG:-$TARGET/metro.config.js}"
|
|
56
|
+
else
|
|
57
|
+
PRODUCT_METRO_CONFIG="${EXPO_OVERRIDE_METRO_CONFIG:-$TARGET/metro.config.js}"
|
|
58
|
+
fi
|
|
59
|
+
export MM_HARNESS_METRO_PROJECT_ROOT="$TARGET"
|
|
60
|
+
export MM_HARNESS_METRO_BASE_CONFIG="$PRODUCT_METRO_CONFIG"
|
|
61
|
+
export EXPO_OVERRIDE_METRO_CONFIG="$HARNESS_METRO_CONFIG"
|
|
62
|
+
MOBILE_METRO_REQUIRED_ENV="${MOBILE_METRO_REQUIRED_ENV:-MM_INFURA_PROJECT_ID METAMASK_BUILD_TYPE METAMASK_ENVIRONMENT} MM_HARNESS_METRO_PROJECT_ROOT MM_HARNESS_METRO_BASE_CONFIG EXPO_OVERRIDE_METRO_CONFIG"
|
|
54
63
|
|
|
55
64
|
# RECIPE_RUNTIME_DIR (relative, validated) makes runtime state per-run so jobs
|
|
56
65
|
# sharing one checkout do not collide on metro.log / metro.pid / metro.tmux.
|
|
@@ -59,6 +68,8 @@ mkdir -p "$LOG_DIR"
|
|
|
59
68
|
LOG_FILE="$LOG_DIR/metro.log"
|
|
60
69
|
PID_FILE="$LOG_DIR/metro.pid"
|
|
61
70
|
METRO_BUILD_ENV_FILE="$LOG_DIR/metro-build-env.sh"
|
|
71
|
+
METRO_LOG_GENERATION="$SCRIPT_DIR/metro-log-generation.cjs"
|
|
72
|
+
METRO_LOG_COALESCER="$SCRIPT_DIR/coalesce-metro-log.cjs"
|
|
62
73
|
|
|
63
74
|
# --- helpers ------------------------------------------------------------------
|
|
64
75
|
|
|
@@ -88,6 +99,9 @@ write_metro_build_env() {
|
|
|
88
99
|
done
|
|
89
100
|
)" || { rm -f "$temporary"; return 1; }
|
|
90
101
|
eval "$product_values"
|
|
102
|
+
required_MM_HARNESS_METRO_PROJECT_ROOT="$MM_HARNESS_METRO_PROJECT_ROOT"
|
|
103
|
+
required_MM_HARNESS_METRO_BASE_CONFIG="$MM_HARNESS_METRO_BASE_CONFIG"
|
|
104
|
+
required_EXPO_OVERRIDE_METRO_CONFIG="$EXPO_OVERRIDE_METRO_CONFIG"
|
|
91
105
|
[ "$CLEAR" = true ] && clear_value=1
|
|
92
106
|
{
|
|
93
107
|
printf 'export MM_HARNESS_METRO_WATCHER_PORT=%q\n' "$PORT"
|
|
@@ -160,23 +174,21 @@ write_metro_runner() {
|
|
|
160
174
|
#!/usr/bin/env bash
|
|
161
175
|
set -uo pipefail
|
|
162
176
|
cd "$(printf '%q' "$TARGET")"
|
|
163
|
-
: > "$(printf '%q' "$LOG_FILE")"
|
|
164
177
|
export EXPO_NO_TYPESCRIPT_SETUP=1
|
|
165
178
|
export WATCHER_PORT="$(printf '%q' "$PORT")" METRO_PORT="$(printf '%q' "$PORT")"
|
|
166
179
|
$worker_env_line
|
|
167
|
-
BASH_ENV=$(printf '%q' "$METRO_BUILD_ENV_FILE") command yarn $(printf '%q' "$watcher_script") 2>&1 | tee -a "$(printf '%q' "$LOG_FILE")"
|
|
180
|
+
BASH_ENV=$(printf '%q' "$METRO_BUILD_ENV_FILE") command yarn $(printf '%q' "$watcher_script") 2>&1 | node $(printf '%q' "$METRO_LOG_COALESCER") | tee -a "$(printf '%q' "$LOG_FILE")"
|
|
168
181
|
EOF
|
|
169
182
|
chmod +x "$runner"
|
|
170
183
|
}
|
|
171
184
|
|
|
172
185
|
start_metro_tmux() {
|
|
186
|
+
local runner="$1"
|
|
173
187
|
command -v tmux >/dev/null 2>&1 || return 1
|
|
174
|
-
local session window
|
|
188
|
+
local session window
|
|
175
189
|
session="$(resolve_run_tmux_session "$LOG_DIR")"
|
|
176
190
|
{ [ -n "$session" ] && tmux has-session -t "=$session" 2>/dev/null; } || return 1
|
|
177
191
|
window="metro-${PORT}"
|
|
178
|
-
runner="$LOG_DIR/run-metro-${PORT}.sh"
|
|
179
|
-
write_metro_runner "$runner"
|
|
180
192
|
tmux kill-window -t "${session}:${window}" >/dev/null 2>&1 || true
|
|
181
193
|
tmux new-window -d -t "$session" -n "$window" "exec $(printf '%q' "$runner")" || return 1
|
|
182
194
|
printf '%s:%s\n' "$session" "$window" > "$LOG_DIR/metro.tmux"
|
|
@@ -237,6 +249,14 @@ else
|
|
|
237
249
|
fi
|
|
238
250
|
printf '(Full log: %s — mm-harness logs | mm-harness logs --full)\n' "$LOG_FILE" >&2
|
|
239
251
|
|
|
252
|
+
[ -f "$METRO_LOG_GENERATION" ] && [ -f "$METRO_LOG_COALESCER" ] || {
|
|
253
|
+
printf 'start-metro: Metro log generation helpers are missing; reinstall the runner\n' >&2
|
|
254
|
+
exit 1
|
|
255
|
+
}
|
|
256
|
+
ROTATION_REASON="metro-start"
|
|
257
|
+
[ "$CLEAR" = true ] && ROTATION_REASON="clear-cache-restart"
|
|
258
|
+
node "$METRO_LOG_GENERATION" "$LOG_DIR" "$PORT" "$ROTATION_REASON" >/dev/null || exit 1
|
|
259
|
+
|
|
240
260
|
write_metro_build_env || {
|
|
241
261
|
printf 'start-metro: could not stage the scoped Metro environment\n' >&2
|
|
242
262
|
exit 1
|
|
@@ -244,13 +264,14 @@ write_metro_build_env || {
|
|
|
244
264
|
|
|
245
265
|
STARTED_METRO_PID=""
|
|
246
266
|
STARTED_METRO_TMUX=""
|
|
247
|
-
|
|
267
|
+
METRO_RUNNER="$LOG_DIR/run-metro-${PORT}.sh"
|
|
268
|
+
write_metro_runner "$METRO_RUNNER"
|
|
269
|
+
if start_metro_tmux "$METRO_RUNNER"; then
|
|
248
270
|
STARTED_METRO_TMUX="$(cat "$LOG_DIR/metro.tmux" 2>/dev/null || true)"
|
|
249
271
|
else
|
|
250
272
|
# Metro runs detached, writing to the log. The tmux window is a read-only tail.
|
|
251
273
|
(
|
|
252
274
|
cd "$TARGET"
|
|
253
|
-
: > "$LOG_FILE"
|
|
254
275
|
export EXPO_NO_TYPESCRIPT_SETUP=1
|
|
255
276
|
export WATCHER_PORT="${PORT}" METRO_PORT="${PORT}"
|
|
256
277
|
if [ -n "$METRO_WORKERS" ]; then
|
|
@@ -264,6 +285,7 @@ else
|
|
|
264
285
|
--log "$LOG_FILE"
|
|
265
286
|
--pid-file "$PID_FILE"
|
|
266
287
|
--build-env "$METRO_BUILD_ENV_FILE"
|
|
288
|
+
--runner "$METRO_RUNNER"
|
|
267
289
|
)
|
|
268
290
|
[ -z "$METRO_WORKERS" ] || launcher_args+=(--workers "$METRO_WORKERS")
|
|
269
291
|
[ "$CLEAR" = true ] && launcher_args+=(--clear)
|
|
@@ -83,15 +83,14 @@ if [ -n "$fwd_pid" ]; then
|
|
|
83
83
|
esac
|
|
84
84
|
fi
|
|
85
85
|
rm -f "$FWD_PID_FILE"
|
|
86
|
-
pkill -f "console-forwarder.cjs --port
|
|
86
|
+
pkill -f "console-forwarder.cjs --port $PORT --out $TARGET/" 2>/dev/null
|
|
87
87
|
|
|
88
|
-
#
|
|
89
|
-
#
|
|
90
|
-
#
|
|
91
|
-
# Guarded: the reap lib may be absent in a minimal install; skip rather than abort.
|
|
88
|
+
# Reap a detached bundler for this exact port when its listener disappeared
|
|
89
|
+
# before the normal stop path. Other ports in the same checkout are separate
|
|
90
|
+
# runtime generations and must remain untouched.
|
|
92
91
|
if [ -f "$SCRIPT_DIR/../shared/reap-checkout-metros.sh" ]; then
|
|
93
92
|
. "$SCRIPT_DIR/../shared/reap-checkout-metros.sh"
|
|
94
|
-
|
|
93
|
+
reap_checkout_metros_on_port "$TARGET" "$PORT" || true
|
|
95
94
|
fi
|
|
96
95
|
|
|
97
96
|
# Close the read-only log-tail window start-metro opened, if it is still there.
|
|
@@ -40,6 +40,23 @@ LIST
|
|
|
40
40
|
return 0
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
reap_checkout_metros_on_port() {
|
|
44
|
+
local repo="$1" port="$2" reaped="" pid args
|
|
45
|
+
[ -n "$repo" ] && [ -n "$port" ] || return 0
|
|
46
|
+
while IFS= read -r pid; do
|
|
47
|
+
[ -n "$pid" ] || continue
|
|
48
|
+
args="$(ps -o args= -p "$pid" 2>/dev/null || true)"
|
|
49
|
+
case " $args " in *" --port $port "*|*" --port=$port "*) ;; *) continue ;; esac
|
|
50
|
+
kill "$pid" 2>/dev/null && reaped="$reaped $pid"
|
|
51
|
+
done <<LIST
|
|
52
|
+
$(_checkout_metro_pids "$repo")
|
|
53
|
+
LIST
|
|
54
|
+
if [ -n "$reaped" ]; then
|
|
55
|
+
printf 'Reaped leaked Metro bundler(s) for checkout port %s:%s\n' "$port" "$reaped" >&2
|
|
56
|
+
fi
|
|
57
|
+
return 0
|
|
58
|
+
}
|
|
59
|
+
|
|
43
60
|
detect_checkout_metros() {
|
|
44
61
|
local repo="$1" live_port="${2:-}" pid args
|
|
45
62
|
[ -n "$repo" ] || return 0
|
|
@@ -2,13 +2,11 @@ import { execFileSync } from "node:child_process";
|
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import {
|
|
5
|
-
clearDecisionState,
|
|
6
5
|
depsCheck,
|
|
7
6
|
recordDepsBaseline
|
|
8
7
|
} from "@farmslot/recipe-harness/runtime/deps-readiness";
|
|
9
8
|
import {
|
|
10
9
|
analyzeBundleLog,
|
|
11
|
-
evaluatePersistentBundleError,
|
|
12
10
|
supersededErrorCapture
|
|
13
11
|
} from "@farmslot/recipe-harness/runtime/log-analysis";
|
|
14
12
|
import { probeMetroPackager } from "@farmslot/recipe-harness/runtime/metro-probe";
|
|
@@ -160,9 +158,6 @@ async function computeMobileReadiness(resolved, options, fast) {
|
|
|
160
158
|
}
|
|
161
159
|
const metroLog = metroLogCheck(resolved, options.metroLog);
|
|
162
160
|
const metro = options.watcherPort ? await probeMetroPackager(options.watcherPort) : { status: "skipped" };
|
|
163
|
-
if (metroLog.status === "ok") {
|
|
164
|
-
clearDecisionState(resolved, "bundle-error-state.json");
|
|
165
|
-
}
|
|
166
161
|
const checks = { deps, metroLog, metro };
|
|
167
162
|
if (deps.status === "missing") {
|
|
168
163
|
return {
|
|
@@ -229,22 +224,6 @@ async function computeMobileReadiness(resolved, options, fast) {
|
|
|
229
224
|
const depsSatisfied = deps.status === "current";
|
|
230
225
|
if (depsSatisfied && (metroLog.reason === "stale-bundle-error" || metroLog.reason === "bundle-error")) {
|
|
231
226
|
const excerpt = metroLog.excerpt ?? "";
|
|
232
|
-
const persistent = evaluatePersistentBundleError(resolved, excerpt);
|
|
233
|
-
if (persistent.blocked) {
|
|
234
|
-
return {
|
|
235
|
-
schemaVersion: 1,
|
|
236
|
-
adapter: "mobile",
|
|
237
|
-
target: resolved,
|
|
238
|
-
decision: "blocked",
|
|
239
|
-
reasonCode: "bundle-error-persistent",
|
|
240
|
-
reasons: [
|
|
241
|
-
"Metro bundle keeps failing with the same error after a cache-cleared relaunch was already suggested; fix the bundle error in app code before retrying recipe up.",
|
|
242
|
-
...excerpt ? [excerpt] : []
|
|
243
|
-
],
|
|
244
|
-
checks,
|
|
245
|
-
actions: []
|
|
246
|
-
};
|
|
247
|
-
}
|
|
248
227
|
return {
|
|
249
228
|
schemaVersion: 1,
|
|
250
229
|
adapter: "mobile",
|
|
@@ -26,6 +26,7 @@ function resolveAndroidModel(serial) {
|
|
|
26
26
|
}
|
|
27
27
|
function setAndroidDeviceEnv(id, fallbackName) {
|
|
28
28
|
process.env.PLATFORM = "android";
|
|
29
|
+
process.env.MM_HARNESS_EXPLICIT_PLATFORM = "android";
|
|
29
30
|
process.env.ADB_SERIAL = id;
|
|
30
31
|
process.env.ANDROID_SERIAL = id;
|
|
31
32
|
process.env.ANDROID_DEVICE = id;
|
|
@@ -33,9 +34,11 @@ function setAndroidDeviceEnv(id, fallbackName) {
|
|
|
33
34
|
if (model) process.env.ANDROID_TARGET_DEVICE_NAME = model;
|
|
34
35
|
else delete process.env.ANDROID_TARGET_DEVICE_NAME;
|
|
35
36
|
process.env.IOS_SIMULATOR = "";
|
|
37
|
+
process.env.SIM_UDID = "";
|
|
36
38
|
}
|
|
37
39
|
function setIosDeviceEnv(id, fallbackName) {
|
|
38
40
|
process.env.PLATFORM = "ios";
|
|
41
|
+
process.env.MM_HARNESS_EXPLICIT_PLATFORM = "ios";
|
|
39
42
|
process.env.IOS_SIMULATOR = fallbackName?.trim() || id;
|
|
40
43
|
process.env.SIM_UDID = id;
|
|
41
44
|
process.env.ADB_SERIAL = "";
|