@deeeed/metamask-harness 0.41.0 → 0.42.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.
Files changed (44) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +7 -0
  3. package/adapters/manifest.json +25 -1
  4. package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +70 -10
  5. package/adapters/mobile/bridge-runtime/console-forwarder.cjs +115 -15
  6. package/adapters/mobile/bridge-runtime/lib/cdp-broker.cjs +752 -0
  7. package/adapters/mobile/bridge-runtime/lib/devtools-proxy.cjs +177 -0
  8. package/adapters/mobile/bridge-runtime/lib/target-discovery.cjs +13 -3
  9. package/adapters/mobile/coalesce-metro-log.cjs +24 -0
  10. package/adapters/mobile/launch-metro.cjs +9 -8
  11. package/adapters/mobile/metro-log-generation.cjs +106 -0
  12. package/adapters/mobile/reload-app.mjs +67 -0
  13. package/adapters/mobile/start-console-forwarder.sh +17 -2
  14. package/adapters/mobile/start-metro.sh +23 -18
  15. package/adapters/mobile/stop-metro.sh +15 -7
  16. package/adapters/shared/open-debug.mjs +172 -2
  17. package/adapters/shared/reap-checkout-metros.sh +17 -0
  18. package/dist/adapters/extension/network-observer.js +300 -0
  19. package/dist/adapters/mobile/metro-env.js +0 -5
  20. package/dist/adapters/mobile/prepare.js +1 -3
  21. package/dist/adapters/mobile/runtime-decision.js +6 -30
  22. package/dist/adapters.js +14 -1
  23. package/dist/cli-commands.js +6 -3
  24. package/dist/cli.js +4 -0
  25. package/dist/command-contract.js +3 -0
  26. package/dist/commands/call.js +45 -20
  27. package/dist/commands/launch/index.js +25 -5
  28. package/dist/commands/reload.js +80 -0
  29. package/dist/commands/run.js +49 -22
  30. package/dist/mm-harness-cli.js +17 -1
  31. package/dist/network-observation.js +271 -0
  32. package/docs/NETWORK-CAPTURE.md +98 -0
  33. package/docs/QA.md +2 -0
  34. package/docs/RECIPES.md +10 -0
  35. package/library/actions/mobile/app/network_assert.mjs +14 -0
  36. package/library/actions/mobile/app/network_capture.mjs +72 -0
  37. package/library/actions/mobile/platform/bridge.mjs +7 -2
  38. package/library/actions/shared/app/network-artifact.mjs +10 -0
  39. package/library/actions/shared/app/network-assert.mjs +154 -0
  40. package/library/manifests/extension.action-manifest.json +88 -0
  41. package/library/manifests/mobile.action-manifest.json +107 -0
  42. package/library/recipes/mobile/perps/performance.recipe.json +11 -11
  43. package/package.json +1 -1
  44. package/scripts/completions.sh +2 -1
@@ -0,0 +1,177 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const { WebSocket, WebSocketServer } = require('ws');
5
+
6
+ const MAX_FRAME_BYTES = 8 * 1024 * 1024;
7
+ const SESSION_TIMEOUT_MS = 10_000;
8
+ const EVENT_GATED_DOMAINS = new Set([
9
+ 'Debugger',
10
+ 'Log',
11
+ 'Network',
12
+ 'Page',
13
+ 'ReactNativeApplication',
14
+ 'Runtime',
15
+ ]);
16
+
17
+ function createDevtoolsProxy({
18
+ descriptorPath,
19
+ sessions,
20
+ sendCommand,
21
+ requestDiscovery,
22
+ allowedOrigins,
23
+ }) {
24
+ if (!Array.isArray(allowedOrigins) || allowedOrigins.length === 0) {
25
+ throw new Error('DevTools proxy requires at least one allowed Origin');
26
+ }
27
+ let listeningPort = null;
28
+ const clients = new Set();
29
+ const server = new WebSocketServer({
30
+ host: '127.0.0.1',
31
+ port: 0,
32
+ maxPayload: MAX_FRAME_BYTES,
33
+ });
34
+
35
+ async function waitForSession(deviceId) {
36
+ const ready = sessions.get(deviceId);
37
+ if (ready?.brokerReady) return ready;
38
+ requestDiscovery();
39
+ const deadline = Date.now() + SESSION_TIMEOUT_MS;
40
+ while (Date.now() < deadline) {
41
+ await new Promise((resolve) => setTimeout(resolve, 100));
42
+ const session = sessions.get(deviceId);
43
+ if (session?.brokerReady) return session;
44
+ }
45
+ throw new Error('Hermes runtime is unavailable or reloading; retry the command');
46
+ }
47
+
48
+ function writeDescriptor() {
49
+ if (!listeningPort) return;
50
+ fs.writeFileSync(
51
+ descriptorPath,
52
+ `${JSON.stringify({ schemaVersion: 1, pid: process.pid, port: listeningPort })}\n`,
53
+ { mode: 0o600 },
54
+ );
55
+ }
56
+
57
+ async function forward(client, message) {
58
+ if (
59
+ !message ||
60
+ typeof message !== 'object' ||
61
+ !Number.isInteger(message.id) ||
62
+ typeof message.method !== 'string'
63
+ ) {
64
+ return;
65
+ }
66
+ if (message.method.endsWith('.disable')) {
67
+ client.enabled.delete(message.method.replace(/\.disable$/u, '.enable'));
68
+ if (client.socket.readyState === WebSocket.OPEN) {
69
+ client.socket.send(JSON.stringify({ id: message.id, result: {} }));
70
+ }
71
+ return;
72
+ }
73
+ const isEnable = message.method.endsWith('.enable');
74
+ if (isEnable) {
75
+ client.enabled.set(message.method, message.params || {});
76
+ }
77
+ try {
78
+ const session = await waitForSession(client.deviceId);
79
+ const result = await sendCommand(
80
+ session,
81
+ message.method,
82
+ message.params || {},
83
+ SESSION_TIMEOUT_MS,
84
+ );
85
+ if (client.socket.readyState === WebSocket.OPEN) {
86
+ client.socket.send(JSON.stringify({ id: message.id, result }));
87
+ }
88
+ } catch (error) {
89
+ if (isEnable) client.enabled.delete(message.method);
90
+ if (client.socket.readyState === WebSocket.OPEN) {
91
+ client.socket.send(
92
+ JSON.stringify({
93
+ id: message.id,
94
+ error: {
95
+ code: -32000,
96
+ message: String(error?.message || error).slice(0, 256),
97
+ },
98
+ }),
99
+ );
100
+ }
101
+ }
102
+ }
103
+
104
+ server.on('connection', (socket, request) => {
105
+ const url = new URL(request.url || '/', 'http://127.0.0.1');
106
+ const deviceId = url.searchParams.get('device');
107
+ if (
108
+ !deviceId ||
109
+ !allowedOrigins.includes(request.headers.origin)
110
+ ) {
111
+ socket.close(1008, 'invalid DevTools proxy Origin or device');
112
+ return;
113
+ }
114
+ for (const client of clients) {
115
+ if (client.deviceId === deviceId) {
116
+ client.socket.close(1000, 'replaced by a newer DevTools window');
117
+ }
118
+ }
119
+ const client = { socket, deviceId, enabled: new Map() };
120
+ clients.add(client);
121
+ socket.on('message', (data) => {
122
+ let message;
123
+ try {
124
+ message = JSON.parse(String(data));
125
+ } catch {
126
+ return;
127
+ }
128
+ void forward(client, message);
129
+ });
130
+ const drop = () => clients.delete(client);
131
+ socket.on('close', drop);
132
+ socket.on('error', drop);
133
+ });
134
+
135
+ server.on('listening', () => {
136
+ const address = server.address();
137
+ if (!address || typeof address === 'string') return;
138
+ listeningPort = address.port;
139
+ writeDescriptor();
140
+ });
141
+
142
+ return {
143
+ onSessionOpen(deviceId, session) {
144
+ for (const client of clients) {
145
+ if (client.deviceId !== deviceId) continue;
146
+ for (const [method, params] of client.enabled) {
147
+ void sendCommand(session, method, params, SESSION_TIMEOUT_MS).catch(
148
+ () => undefined,
149
+ );
150
+ }
151
+ }
152
+ },
153
+ onCdpEvent(deviceId, method, params) {
154
+ const payload = JSON.stringify({ method, params });
155
+ const domain = method.split('.', 1)[0];
156
+ for (const client of clients) {
157
+ if (
158
+ client.deviceId === deviceId &&
159
+ client.socket.readyState === WebSocket.OPEN &&
160
+ (!EVENT_GATED_DOMAINS.has(domain) ||
161
+ client.enabled.has(`${domain}.enable`))
162
+ ) {
163
+ client.socket.send(payload);
164
+ }
165
+ }
166
+ },
167
+ close() {
168
+ for (const client of clients) client.socket.close();
169
+ server.close();
170
+ try {
171
+ fs.unlinkSync(descriptorPath);
172
+ } catch {}
173
+ },
174
+ };
175
+ }
176
+
177
+ module.exports = { createDevtoolsProxy };
@@ -138,7 +138,7 @@ function targetDeviceIdentity(target) {
138
138
  * page 1 = native C++ runtime, page 2+ = JS runtime (where __AGENTIC__ lives)
139
139
  * - We probe candidates to find the one with __AGENTIC__ installed
140
140
  */
141
- async function discoverTarget(port) {
141
+ async function discoverTarget(port, { probe = true } = {}) {
142
142
  const listUrl = `http://localhost:${port}/json/list`;
143
143
  const androidTargetName = loadAndroidTargetDeviceName();
144
144
  const androidDevice = loadAndroidDevice();
@@ -162,7 +162,7 @@ async function discoverTarget(port) {
162
162
  if (androidPinned) {
163
163
  const pinnedCandidates = runtimeCandidates.filter(matchesAndroidPin);
164
164
  for (const candidate of pinnedCandidates) {
165
- if (await probeTarget(candidate.webSocketDebuggerUrl)) {
165
+ if (!probe || (await probeTarget(candidate.webSocketDebuggerUrl))) {
166
166
  acceptedPinnedCandidate = candidate;
167
167
  return true;
168
168
  }
@@ -174,7 +174,7 @@ async function discoverTarget(port) {
174
174
  (candidate) => candidate.deviceName === simName,
175
175
  );
176
176
  for (const candidate of pinnedCandidates) {
177
- if (await probeTarget(candidate.webSocketDebuggerUrl)) {
177
+ if (!probe || (await probeTarget(candidate.webSocketDebuggerUrl))) {
178
178
  acceptedPinnedCandidate = candidate;
179
179
  return true;
180
180
  }
@@ -307,6 +307,16 @@ async function discoverTarget(port) {
307
307
  }
308
308
  }
309
309
 
310
+ // The persistent console forwarder already owns and validates brokered CDP
311
+ // sessions. Re-probing here would evict that debugger; preserve the normal
312
+ // platform/device filtering and select the highest-ranked runtime instead.
313
+ if (!probe) {
314
+ return {
315
+ wsUrl: candidates[0].webSocketDebuggerUrl,
316
+ deviceName: candidates[0].deviceName || '',
317
+ };
318
+ }
319
+
310
320
  // Sort by page number descending (JS runtime has higher page number than C++
311
321
  // native). rankRuntimeCandidates output arrives pre-sorted; this re-sort is
312
322
  // load-bearing only when the raw targets.filter() fallback above repopulated
@@ -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 logFd = fs.openSync(log, 'a');
41
- const childArgs = ['watch'];
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('yarn', childArgs, {
47
+ const child = spawn(runner, [], {
46
48
  cwd: target,
47
49
  detached: true,
48
50
  env,
49
- stdio: ['ignore', logFd, logFd],
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: `yarn ${childArgs[0]}`,
68
+ command: runner,
68
69
  }, null, 2)}\n`,
69
70
  );
70
71
  child.unref();
@@ -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
+ }
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { createRequire } from 'node:module';
4
+
5
+ const require = createRequire(import.meta.url);
6
+ const WebSocket = require('ws');
7
+
8
+ function parseArgs(argv) {
9
+ const args = { port: process.env.WATCHER_PORT || process.env.METRO_PORT || '8081', json: false };
10
+ for (let index = 0; index < argv.length; index += 1) {
11
+ if (argv[index] === '--port') args.port = argv[++index];
12
+ else if (argv[index] === '--json') args.json = true;
13
+ else if (argv[index] === '--help' || argv[index] === '-h') {
14
+ console.log('Usage: reload-app.mjs [--port <Metro port>] [--json]');
15
+ process.exit(0);
16
+ } else {
17
+ throw new Error(`Unknown argument: ${argv[index]}`);
18
+ }
19
+ }
20
+ return args;
21
+ }
22
+
23
+ async function reload(port) {
24
+ const numericPort = Number.parseInt(String(port), 10);
25
+ if (!Number.isInteger(numericPort) || numericPort <= 0) {
26
+ throw new Error(`Metro port is invalid: ${port}`);
27
+ }
28
+ await new Promise((resolve, reject) => {
29
+ const socket = new WebSocket(`ws://127.0.0.1:${numericPort}/message`);
30
+ const timeout = setTimeout(() => {
31
+ socket.close();
32
+ reject(new Error(`Metro reload timed out on port ${numericPort}`));
33
+ }, 5000);
34
+ socket.on('open', () => {
35
+ socket.send(JSON.stringify({ method: 'reload', version: 2 }));
36
+ setTimeout(() => {
37
+ clearTimeout(timeout);
38
+ socket.close();
39
+ resolve();
40
+ }, 50);
41
+ });
42
+ socket.on('error', () => {
43
+ clearTimeout(timeout);
44
+ reject(new Error(`Metro is not reachable on port ${numericPort}`));
45
+ });
46
+ });
47
+ return {
48
+ ok: true,
49
+ adapter: 'mobile',
50
+ method: 'metro-message',
51
+ command: 'reload',
52
+ port: numericPort,
53
+ };
54
+ }
55
+
56
+ const args = parseArgs(process.argv.slice(2));
57
+ reload(args.port)
58
+ .then((result) => {
59
+ if (args.json) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
60
+ else console.log(`Reload requested through Metro :${result.port}`);
61
+ })
62
+ .catch((error) => {
63
+ const result = { ok: false, adapter: 'mobile', error: String(error?.message || error) };
64
+ if (args.json) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
65
+ else console.error(result.error);
66
+ process.exitCode = 1;
67
+ });
@@ -32,12 +32,27 @@ APP_LOG_FILE="$LOG_DIR/app-console.log"
32
32
  { [ -f "$FORWARDER" ] && command -v node >/dev/null 2>&1; } || exit 0
33
33
  mkdir -p "$LOG_DIR"
34
34
 
35
+ stopped_pids=()
36
+ stop_forwarder() {
37
+ local pid="$1"
38
+ case " ${stopped_pids[*]-} " in
39
+ *" $pid "*) return ;;
40
+ esac
41
+ stopped_pids+=("$pid")
42
+ kill "$pid" 2>/dev/null || true
43
+ for _ in {1..20}; do
44
+ kill -0 "$pid" 2>/dev/null || return
45
+ sleep 0.05
46
+ done
47
+ kill -KILL "$pid" 2>/dev/null || true
48
+ }
49
+
35
50
  # One debugger owns a React Native page. Reap any collector for this Metro port,
36
51
  # including one left by an older globally installed harness.
37
52
  while read -r pid cmd; do
38
53
  case "$cmd" in
39
54
  *console-forwarder.cjs*" --port $PORT "*|*console-forwarder.cjs*" --port=$PORT "*)
40
- kill "$pid" 2>/dev/null || true
55
+ stop_forwarder "$pid"
41
56
  ;;
42
57
  esac
43
58
  done < <(ps -axo pid=,command= 2>/dev/null || true)
@@ -46,7 +61,7 @@ if [ -f "$PID_FILE" ]; then
46
61
  old_pid="$(cat "$PID_FILE" 2>/dev/null || true)"
47
62
  if [ -n "$old_pid" ]; then
48
63
  case "$(ps -p "$old_pid" -o command= 2>/dev/null)" in
49
- *console-forwarder.cjs*) kill "$old_pid" 2>/dev/null || true ;;
64
+ *console-forwarder.cjs*) stop_forwarder "$old_pid" ;;
50
65
  esac
51
66
  fi
52
67
  rm -f "$PID_FILE"
@@ -4,8 +4,8 @@
4
4
  # Checks if Metro is already running and owned by this checkout with required env vars.
5
5
  # Clears stale or foreign listeners before starting. Uses the checkout's canonical
6
6
  # yarn watch script and sets EXPO_NO_TYPESCRIPT_SETUP=1. Cache clearing is
7
- # injected as Expo --clear without invoking Mobile's global watchman cleanup.
8
- # Metro owns its default worker count; METRO_MAX_WORKERS is an explicit override.
7
+ # passed to Expo only for the explicit --clear option. Metro defaults to four
8
+ # workers; METRO_MAX_WORKERS remains an explicit override.
9
9
  #
10
10
  # Inputs:
11
11
  # --target <metamask-mobile dir> (default $PWD)
@@ -68,6 +68,8 @@ mkdir -p "$LOG_DIR"
68
68
  LOG_FILE="$LOG_DIR/metro.log"
69
69
  PID_FILE="$LOG_DIR/metro.pid"
70
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"
71
73
 
72
74
  # --- helpers ------------------------------------------------------------------
73
75
 
@@ -172,23 +174,21 @@ write_metro_runner() {
172
174
  #!/usr/bin/env bash
173
175
  set -uo pipefail
174
176
  cd "$(printf '%q' "$TARGET")"
175
- : > "$(printf '%q' "$LOG_FILE")"
176
177
  export EXPO_NO_TYPESCRIPT_SETUP=1
177
178
  export WATCHER_PORT="$(printf '%q' "$PORT")" METRO_PORT="$(printf '%q' "$PORT")"
178
179
  $worker_env_line
179
- 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")"
180
181
  EOF
181
182
  chmod +x "$runner"
182
183
  }
183
184
 
184
185
  start_metro_tmux() {
186
+ local runner="$1"
185
187
  command -v tmux >/dev/null 2>&1 || return 1
186
- local session window runner
188
+ local session window
187
189
  session="$(resolve_run_tmux_session "$LOG_DIR")"
188
190
  { [ -n "$session" ] && tmux has-session -t "=$session" 2>/dev/null; } || return 1
189
191
  window="metro-${PORT}"
190
- runner="$LOG_DIR/run-metro-${PORT}.sh"
191
- write_metro_runner "$runner"
192
192
  tmux kill-window -t "${session}:${window}" >/dev/null 2>&1 || true
193
193
  tmux new-window -d -t "$session" -n "$window" "exec $(printf '%q' "$runner")" || return 1
194
194
  printf '%s:%s\n' "$session" "$window" > "$LOG_DIR/metro.tmux"
@@ -227,8 +227,8 @@ fi
227
227
 
228
228
  stop_metro_listener || exit 1
229
229
 
230
- METRO_WORKERS="${METRO_MAX_WORKERS:-}"
231
- if [ -n "$METRO_WORKERS" ] && ! printf '%s' "$METRO_WORKERS" | grep -Eq '^[1-9][0-9]*$'; then
230
+ METRO_WORKERS="${METRO_MAX_WORKERS:-4}"
231
+ if ! printf '%s' "$METRO_WORKERS" | grep -Eq '^[1-9][0-9]*$'; then
232
232
  printf 'start-metro: METRO_MAX_WORKERS must be a positive integer, got %s\n' "$METRO_WORKERS" >&2
233
233
  exit 2
234
234
  fi
@@ -240,15 +240,18 @@ fi
240
240
  CLEAR_LABEL=""
241
241
  [ "$CLEAR" = true ] && CLEAR_LABEL=", clear"
242
242
 
243
- if [ -n "$METRO_WORKERS" ]; then
244
- printf 'Starting Metro on port %s (workers=%s%s)\n' \
245
- "$PORT" "$METRO_WORKERS" "$CLEAR_LABEL" >&2
246
- else
247
- printf 'Starting Metro on port %s (workers=Metro default%s)\n' \
248
- "$PORT" "$CLEAR_LABEL" >&2
249
- fi
243
+ printf 'Starting Metro on port %s (workers=%s%s)\n' \
244
+ "$PORT" "$METRO_WORKERS" "$CLEAR_LABEL" >&2
250
245
  printf '(Full log: %s — mm-harness logs | mm-harness logs --full)\n' "$LOG_FILE" >&2
251
246
 
247
+ [ -f "$METRO_LOG_GENERATION" ] && [ -f "$METRO_LOG_COALESCER" ] || {
248
+ printf 'start-metro: Metro log generation helpers are missing; reinstall the runner\n' >&2
249
+ exit 1
250
+ }
251
+ ROTATION_REASON="metro-start"
252
+ [ "$CLEAR" = true ] && ROTATION_REASON="clear-cache-restart"
253
+ node "$METRO_LOG_GENERATION" "$LOG_DIR" "$PORT" "$ROTATION_REASON" >/dev/null || exit 1
254
+
252
255
  write_metro_build_env || {
253
256
  printf 'start-metro: could not stage the scoped Metro environment\n' >&2
254
257
  exit 1
@@ -256,13 +259,14 @@ write_metro_build_env || {
256
259
 
257
260
  STARTED_METRO_PID=""
258
261
  STARTED_METRO_TMUX=""
259
- if start_metro_tmux; then
262
+ METRO_RUNNER="$LOG_DIR/run-metro-${PORT}.sh"
263
+ write_metro_runner "$METRO_RUNNER"
264
+ if start_metro_tmux "$METRO_RUNNER"; then
260
265
  STARTED_METRO_TMUX="$(cat "$LOG_DIR/metro.tmux" 2>/dev/null || true)"
261
266
  else
262
267
  # Metro runs detached, writing to the log. The tmux window is a read-only tail.
263
268
  (
264
269
  cd "$TARGET"
265
- : > "$LOG_FILE"
266
270
  export EXPO_NO_TYPESCRIPT_SETUP=1
267
271
  export WATCHER_PORT="${PORT}" METRO_PORT="${PORT}"
268
272
  if [ -n "$METRO_WORKERS" ]; then
@@ -276,6 +280,7 @@ else
276
280
  --log "$LOG_FILE"
277
281
  --pid-file "$PID_FILE"
278
282
  --build-env "$METRO_BUILD_ENV_FILE"
283
+ --runner "$METRO_RUNNER"
279
284
  )
280
285
  [ -z "$METRO_WORKERS" ] || launcher_args+=(--workers "$METRO_WORKERS")
281
286
  [ "$CLEAR" = true ] && launcher_args+=(--clear)
@@ -21,7 +21,7 @@ while [ $# -gt 0 ]; do
21
21
  esac
22
22
  done
23
23
 
24
- TARGET="$(cd "$TARGET" && pwd)"
24
+ TARGET="$(cd "$TARGET" && pwd -P)"
25
25
 
26
26
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
27
27
  # shellcheck disable=SC1091
@@ -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 .* --out $TARGET/" 2>/dev/null
86
+ pkill -f "console-forwarder.cjs --port $PORT --out $TARGET/" 2>/dev/null
87
87
 
88
- # Port-scoped stop above misses bundlers a prior launch left on a DIFFERENT port
89
- # (port drift / missing pid file). Sweep every Metro bound to this checkout so
90
- # stop fully cleans up the leak that stacked bundlers across relaunches.
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
- reap_checkout_metros "$TARGET" || true
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.
@@ -103,6 +102,15 @@ if [ -f "$TMUX_FILE" ] && command -v tmux >/dev/null 2>&1; then
103
102
  fi
104
103
  rm -f "$TMUX_FILE"
105
104
 
105
+ # A stopped checkout must not remain in Watchman's global root set. Metro will
106
+ # add the root again on the next launch; removing only this exact target keeps
107
+ # every other active slot untouched.
108
+ if command -v watchman >/dev/null 2>&1; then
109
+ if watchman watch-del "$TARGET" >/dev/null 2>&1; then
110
+ printf 'Removed Watchman root %s\n' "$TARGET" >&2
111
+ fi
112
+ fi
113
+
106
114
  # Reaching here = cleanup done (nothing needed stopping, or it stopped and
107
115
  # windows/pids were cleared). The idempotent-stop contract is success; do not
108
116
  # leak an incidental non-zero from the last command on any platform.