@houwert/conductor 0.13.1 → 0.15.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.
@@ -1,9 +1,28 @@
1
1
  "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
2
5
  Object.defineProperty(exports, "__esModule", { value: true });
3
6
  exports.HELP = void 0;
4
7
  exports.memory = memory;
5
- exports.HELP = ` memory [<appId>] Show device + app memory usage and object counts`;
8
+ exports.HELP = ` memory [<appId>] Show device + app memory usage
9
+ --objects Include per-class object counts (iOS: heap; slower)
10
+ --leaks Run leak detection (iOS only; slow, can pause the app)
11
+ --all Shorthand for --objects --leaks
12
+ --top <n> Limit object/region tables (default 20)
13
+ --save <name> Save the report as a snapshot
14
+ --diff <name> Diff snapshot <name> vs current
15
+ --diff <name> --vs <other> Diff two saved snapshots
16
+ --snapshots List saved snapshots
17
+ --no-gc Skip the pre-measurement GC (web only)
18
+ --filter <regex> Filter object/class tables by name (regex)
19
+ --growth-only In diff output, only show positive deltas (leak-hunting)
20
+ --json Emit JSON instead of formatted text`;
21
+ const node_fs_1 = require("node:fs");
22
+ const node_path_1 = __importDefault(require("node:path"));
23
+ const node_os_1 = __importDefault(require("node:os"));
6
24
  const runner_js_1 = require("../runner.js");
25
+ const sdk_js_1 = require("../android/sdk.js");
7
26
  const session_js_1 = require("../session.js");
8
27
  const foreground_app_js_1 = require("./foreground-app.js");
9
28
  const output_js_1 = require("../output.js");
@@ -11,18 +30,36 @@ const bootstrap_js_1 = require("../drivers/bootstrap.js");
11
30
  const ios_js_1 = require("../drivers/ios.js");
12
31
  const android_js_1 = require("../drivers/android.js");
13
32
  const web_js_1 = require("../drivers/web.js");
33
+ const hprof_js_1 = require("./hprof.js");
14
34
  async function resolveDeviceId(sessionName) {
15
35
  if (sessionName !== 'default')
16
36
  return sessionName;
17
37
  const session = await (0, session_js_1.getSession)(sessionName);
18
38
  return session.deviceId ?? (await (0, runner_js_1.detectFirstDevice)());
19
39
  }
20
- async function resolveAppId(explicit, sessionName, deviceId) {
40
+ async function resolveAppId(explicit, sessionName, deviceId, platform) {
21
41
  if (explicit)
22
42
  return explicit;
23
43
  // No arg: always resolve from the live foreground app, not the session file.
24
44
  // The session's appId reflects the last `launch-app` call, which can be stale
25
45
  // if the user switched apps on the device by other means.
46
+ //
47
+ // On Android, foreground-app detection only needs `adb shell dumpsys` — going
48
+ // through getDriver() would force the gRPC daemon to start (and require the
49
+ // driver APK to be installed), which is wasteful for a read-only memory dump.
50
+ if (platform === 'android') {
51
+ const dump = await (0, runner_js_1.spawnCommand)((0, sdk_js_1.resolveAndroidTool)('adb'), ['-s', deviceId, 'shell', 'dumpsys', 'activity', 'activities'], { env: (0, sdk_js_1.androidSpawnEnv)() });
52
+ if (dump.success) {
53
+ // Different Android versions print this differently:
54
+ // API 28-: mResumedActivity: ActivityRecord{... pkg/.Activity ...}
55
+ // API 29+: topResumedActivity=ActivityRecord{... pkg/.Activity ...}
56
+ // ResumedActivity: ActivityRecord{... pkg/.Activity ...}
57
+ const m = dump.stdout.match(/(?:m|top)?ResumedActivity[=:].*?([a-zA-Z][a-zA-Z0-9_]*(?:\.[a-zA-Z][a-zA-Z0-9_]*)+)\//);
58
+ if (m)
59
+ return m[1];
60
+ }
61
+ return undefined;
62
+ }
26
63
  try {
27
64
  const driver = await (0, runner_js_1.getDriver)(sessionName);
28
65
  if (driver instanceof android_js_1.AndroidDriver)
@@ -60,7 +97,6 @@ function parseAndroidDumpsysMeminfo(out) {
60
97
  const pidMatch = out.match(/\*\* MEMINFO in pid (\d+)/);
61
98
  const pid = pidMatch ? Number(pidMatch[1]) : undefined;
62
99
  // App Summary block — most useful, single-line entries with KB values.
63
- // e.g. " Java Heap: 12345"
64
100
  const summary = out.match(/App Summary[\s\S]*?(?:\n\s*\n|TOTAL:)/);
65
101
  if (summary) {
66
102
  const block = summary[0];
@@ -85,25 +121,14 @@ function parseAndroidDumpsysMeminfo(out) {
85
121
  if (totalSwap)
86
122
  app['totalSwapBytes'] = Number(totalSwap[1]) * 1024;
87
123
  }
88
- // Fallback: TOTAL line in the main table — "TOTAL 12345 ..."
89
124
  if (app.totalPssBytes === undefined) {
90
125
  const total = out.match(/^\s*TOTAL\s+(\d+)/m);
91
126
  if (total)
92
127
  app.totalPssBytes = Number(total[1]) * 1024;
93
128
  }
94
- // Objects block:
95
- // Objects
96
- // Views: 3 ViewRootImpl: 1
97
- // AppContexts: 2 Activities: 1
98
- // Assets: 7 AssetManagers: 0
99
- // Local Binders: 5 Proxy Binders: 17
100
- // Parcel memory: 1 Parcel count: 4
101
- // Death Recipients: 0 OpenSSL Sockets: 0
102
- // WebViews: 0
103
129
  const objSection = out.match(/Objects[\s\S]*?(?:\n\s*\n|SQL\s|DATABASES|$)/);
104
130
  if (objSection) {
105
131
  const text = objSection[0];
106
- // Capture every "Label: number" pair (labels may have spaces).
107
132
  const re = /([A-Za-z][A-Za-z _]*?):\s+(\d+)/g;
108
133
  let m;
109
134
  while ((m = re.exec(text)) !== null) {
@@ -115,9 +140,57 @@ function parseAndroidDumpsysMeminfo(out) {
115
140
  }
116
141
  return { app, objects, pid };
117
142
  }
118
- async function collectAndroid(deviceId, appId) {
143
+ function parseAndroidUnreachable(out) {
144
+ // dumpsys meminfo --unreachable <pid> output:
145
+ // Unreachable memory:
146
+ // 1234 bytes in 5 unreachable allocations
147
+ // ABI: 'arm64'
148
+ //
149
+ // 192 bytes unreachable at 12abcdef
150
+ // first 32 bytes of contents:
151
+ // ...
152
+ // #00 pc 000... /system/lib64/libc.so (malloc+24)
153
+ // #01 pc 000... /data/app/.../lib/libfoo.so (foo_init+32)
154
+ const summary = out.match(/(\d+)\s+bytes?\s+in\s+(\d+)\s+unreachable\s+allocations?/i);
155
+ const totalBytes = summary ? Number(summary[1]) : 0;
156
+ const totalCount = summary ? Number(summary[2]) : 0;
157
+ // Aggregate by the most-specific (last user) library frame in each backtrace.
158
+ // System libs (libc, liblog, libart) are usually the immediate allocator —
159
+ // the user library a frame or two up tells us what's actually leaking.
160
+ const byClass = new Map();
161
+ const blocks = out.split(/(?=^\s*\d+\s+bytes?\s+unreachable\s+at)/m);
162
+ for (const block of blocks) {
163
+ const head = block.match(/^\s*(\d+)\s+bytes?\s+unreachable\s+at/m);
164
+ if (!head)
165
+ continue;
166
+ const size = Number(head[1]);
167
+ // Find first non-libc/-liblog/-libart frame in the backtrace.
168
+ const frames = [...block.matchAll(/#\d+\s+pc\s+\S+\s+(\/\S+?\.so)\s+\(([^)+]+)/g)];
169
+ let owner = '<unknown>';
170
+ for (const f of frames) {
171
+ const lib = f[1];
172
+ const sym = f[2];
173
+ if (/lib(c|m|art|log|dl|cutils)\.so$/.test(lib))
174
+ continue;
175
+ owner = `${sym} [${lib.split('/').pop()}]`;
176
+ break;
177
+ }
178
+ if (owner === '<unknown>' && frames[0]) {
179
+ owner = `${frames[0][2]} [${frames[0][1].split('/').pop()}]`;
180
+ }
181
+ const prev = byClass.get(owner) ?? { count: 0, bytes: 0 };
182
+ prev.count += 1;
183
+ prev.bytes += size;
184
+ byClass.set(owner, prev);
185
+ }
186
+ const classes = [...byClass.entries()]
187
+ .map(([cls, v]) => ({ class: cls, count: v.count, bytes: v.bytes }))
188
+ .sort((a, b) => b.bytes - a.bytes);
189
+ return { totalCount, totalBytes, classes };
190
+ }
191
+ async function collectAndroid(deviceId, appId, opts) {
119
192
  const report = { platform: 'android', deviceId, appId, notes: [] };
120
- const meminfo = await (0, runner_js_1.spawnCommand)('adb', ['-s', deviceId, 'shell', 'cat', '/proc/meminfo']);
193
+ const meminfo = await (0, runner_js_1.spawnCommand)((0, sdk_js_1.resolveAndroidTool)('adb'), ['-s', deviceId, 'shell', 'cat', '/proc/meminfo'], { env: (0, sdk_js_1.androidSpawnEnv)() });
121
194
  if (meminfo.success) {
122
195
  const sys = parseAndroidMeminfo(meminfo.stdout);
123
196
  report.system = {
@@ -131,7 +204,7 @@ async function collectAndroid(deviceId, appId) {
131
204
  report.notes.push(`/proc/meminfo unavailable: ${meminfo.stderr.trim()}`);
132
205
  }
133
206
  if (appId) {
134
- const dump = await (0, runner_js_1.spawnCommand)('adb', ['-s', deviceId, 'shell', 'dumpsys', 'meminfo', appId]);
207
+ const dump = await (0, runner_js_1.spawnCommand)((0, sdk_js_1.resolveAndroidTool)('adb'), ['-s', deviceId, 'shell', 'dumpsys', 'meminfo', appId], { env: (0, sdk_js_1.androidSpawnEnv)() });
135
208
  if (dump.success && !dump.stdout.includes('No process found')) {
136
209
  const { app, objects, pid } = parseAndroidDumpsysMeminfo(dump.stdout);
137
210
  report.app = app;
@@ -141,6 +214,80 @@ async function collectAndroid(deviceId, appId) {
141
214
  else {
142
215
  report.notes.push(`dumpsys meminfo ${appId} returned no data — app may not be running`);
143
216
  }
217
+ // --leaks → dumpsys meminfo --unreachable <pid>. Requires root on stock
218
+ // images; on user builds it returns "Unreachable memory check not supported".
219
+ if (opts.leaks && report.pid) {
220
+ const unreach = await (0, runner_js_1.spawnCommand)('adb', [
221
+ '-s',
222
+ deviceId,
223
+ 'shell',
224
+ 'dumpsys',
225
+ 'meminfo',
226
+ '--unreachable',
227
+ String(report.pid),
228
+ ]);
229
+ if (unreach.success && unreach.stdout.includes('Unreachable memory:')) {
230
+ report.leaks = parseAndroidUnreachable(unreach.stdout);
231
+ }
232
+ else if (unreach.stdout.includes('not supported')) {
233
+ report.notes.push('dumpsys --unreachable not supported on this build (needs userdebug/root).');
234
+ }
235
+ else {
236
+ report.notes.push(`--unreachable unavailable: ${unreach.stderr.trim() || 'no output'}`);
237
+ }
238
+ }
239
+ // --objects → trigger `am dumpheap` and pull the .hprof. We don't parse
240
+ // HPROF binary; the file is meant to be opened in Android Studio's Memory
241
+ // Profiler. The path is recorded so it shows up in the report notes.
242
+ if (opts.objects && report.pid) {
243
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
244
+ const remote = `/data/local/tmp/conductor-${appId}-${ts}.hprof`;
245
+ const dumpRes = await (0, runner_js_1.spawnCommand)('adb', [
246
+ '-s',
247
+ deviceId,
248
+ 'shell',
249
+ 'am',
250
+ 'dumpheap',
251
+ String(report.pid),
252
+ remote,
253
+ ]);
254
+ if (dumpRes.success) {
255
+ await new Promise((r) => setTimeout(r, 1500)); // dumpheap is async; wait for flush
256
+ const localDir = node_path_1.default.join(node_os_1.default.homedir(), '.conductor', 'heap-dumps');
257
+ await node_fs_1.promises.mkdir(localDir, { recursive: true });
258
+ const localFile = node_path_1.default.join(localDir, node_path_1.default.basename(remote));
259
+ const pull = await (0, runner_js_1.spawnCommand)('adb', ['-s', deviceId, 'pull', remote, localFile]);
260
+ if (pull.success) {
261
+ await (0, runner_js_1.spawnCommand)('adb', ['-s', deviceId, 'shell', 'rm', remote]);
262
+ // Parse the HPROF binary for per-class instance counts/bytes — same
263
+ // shape as iOS `heap` and Web V8 snapshot output, so it slots into
264
+ // the existing snapshot/diff workflow.
265
+ try {
266
+ const buf = await node_fs_1.promises.readFile(localFile);
267
+ const { classes, totals, heaps } = (0, hprof_js_1.parseHprof)(buf);
268
+ if (classes.length > 0)
269
+ report.objectClasses = classes;
270
+ report.heapTotals = totals;
271
+ const heapsNote = heaps
272
+ ? ' ' +
273
+ Object.entries(heaps)
274
+ .map(([h, v]) => `${h}: ${v.count.toLocaleString()} obj / ${(v.bytes / 1048576).toFixed(1)} MB`)
275
+ .join(', ')
276
+ : '';
277
+ report.notes.push(`Heap dump saved → ${localFile}${heapsNote} (also openable in Android Studio: Profiler → Memory → Import Heap Dump)`);
278
+ }
279
+ catch (err) {
280
+ report.notes.push(`Heap dump saved → ${localFile} but parse failed: ${err instanceof Error ? err.message : String(err)}`);
281
+ }
282
+ }
283
+ else {
284
+ report.notes.push(`adb pull heap dump failed: ${pull.stderr.trim()}`);
285
+ }
286
+ }
287
+ else {
288
+ report.notes.push(`am dumpheap failed: ${dumpRes.stderr.trim()}`);
289
+ }
290
+ }
144
291
  }
145
292
  if (report.notes.length === 0)
146
293
  delete report.notes;
@@ -148,7 +295,6 @@ async function collectAndroid(deviceId, appId) {
148
295
  }
149
296
  // ── iOS / tvOS ────────────────────────────────────────────────────────────────
150
297
  function parseVmStat(out) {
151
- // vm_stat output uses "page size of 16384 bytes" and counts in pages.
152
298
  const pageMatch = out.match(/page size of (\d+)/);
153
299
  const pageSize = pageMatch ? Number(pageMatch[1]) : 4096;
154
300
  const grab = (label) => {
@@ -188,23 +334,40 @@ async function findIOSPid(deviceId, appId) {
188
334
  }
189
335
  return undefined;
190
336
  }
337
+ function parseFootprint(out) {
338
+ // Header: "Plex [15843]: 64-bit Footprint: 801 MB (16384 bytes per page)"
339
+ // Values can be "B", "KB", "MB", "GB" with a space between number and unit.
340
+ const numUnit = '([\\d.]+\\s*[KMGT]?B)';
341
+ const header = out.match(new RegExp(`Footprint:\\s+${numUnit}`));
342
+ const footprintBytes = header ? humanToBytes(header[1].replace(/\s+/g, '')) : undefined;
343
+ // TOTAL line: " 801 MB 70 MB 4960 KB 5577 TOTAL"
344
+ // Columns: Dirty | Clean | Reclaimable | Regions | "TOTAL"
345
+ const total = out.match(new RegExp(`^\\s*${numUnit}\\s+${numUnit}\\s+${numUnit}\\s+\\d+\\s+TOTAL\\s*$`, 'm'));
346
+ const dirtyBytes = total ? humanToBytes(total[1].replace(/\s+/g, '')) : undefined;
347
+ return { footprintBytes, dirtyBytes };
348
+ }
191
349
  function parseVmmapSummary(out) {
192
350
  const regions = {};
193
351
  const app = {};
194
- // Lines look like: "MALLOC 1.2G 234.5M ..."
195
- // We want the "RESIDENT" column (2nd numeric). The columns are whitespace-delimited.
196
- // Easier: parse the per-region rows under "REGION TYPE" header.
352
+ // Find the per-region body. Layout:
353
+ // VIRTUAL RESIDENT DIRTY ...
354
+ // REGION TYPE SIZE SIZE SIZE ...
355
+ // =========== ======= ======== ===== ...
356
+ // Accelerate framework 128K 128K 128K ...
357
+ // ...
358
+ // TOTAL 5.2G 1.4G 950M ...
197
359
  const headerIdx = out.indexOf('REGION TYPE');
198
360
  if (headerIdx === -1)
199
361
  return { app, regions };
200
- const body = out.slice(headerIdx);
201
- const lines = body.split('\n').slice(1);
202
- for (const raw of lines) {
203
- const line = raw.trimEnd();
204
- if (!line.trim())
362
+ const lines = out.slice(headerIdx).split('\n');
363
+ // Skip the REGION TYPE header line and the ====== separator that follows.
364
+ for (let i = 1; i < lines.length; i++) {
365
+ const line = lines[i].trimEnd();
366
+ const trimmed = line.trim();
367
+ if (!trimmed)
205
368
  continue;
206
- if (line.startsWith('='))
207
- break;
369
+ if (trimmed.startsWith('='))
370
+ continue; // separator row
208
371
  if (line.startsWith('TOTAL')) {
209
372
  // "TOTAL 5.2G 1.4G 950M ..."
210
373
  const m = line.match(/TOTAL\s+\S+\s+(\S+)/);
@@ -221,13 +384,12 @@ function parseVmmapSummary(out) {
221
384
  if (resident !== undefined)
222
385
  regions[name] = resident;
223
386
  }
224
- // Roll up a few well-known regions into the canonical fields.
225
387
  app.nativeHeapBytes = regions['MALLOC'] ?? regions['MALLOC_NANO'];
226
388
  app.stackBytes = regions['Stack'];
227
389
  return { app, regions };
228
390
  }
229
391
  function humanToBytes(s) {
230
- // Matches "1.2G", "234.5M", "950K", "1024", "1.2GB" etc.
392
+ // Matches "1.2G", "234.5M", "950K", "1024", "1.2GB", "0K" etc.
231
393
  const m = s.match(/^([\d.]+)\s*([KMGT]?)B?$/i);
232
394
  if (!m)
233
395
  return undefined;
@@ -243,10 +405,104 @@ function humanToBytes(s) {
243
405
  };
244
406
  return Math.round(n * (mult[m[2].toUpperCase()] ?? 1));
245
407
  }
246
- async function collectIOS(deviceId, platform, appId) {
408
+ // ── iOS heap (per-class object counts) ────────────────────────────────────────
409
+ function parseHeap(out) {
410
+ const classes = [];
411
+ let totals;
412
+ // Locate the "All zones: N nodes (B bytes)" totals line.
413
+ const totalsMatch = out.match(/All zones:\s+(\d+)\s+nodes\s+\((\d+)\s+bytes\)/);
414
+ if (totalsMatch) {
415
+ totals = { count: Number(totalsMatch[1]), bytes: Number(totalsMatch[2]) };
416
+ }
417
+ // Find the table header row. Heap prints:
418
+ // COUNT BYTES AVG CLASS_NAME ... TYPE BINARY
419
+ // ===== ===== === ========== ... ==== ======
420
+ // 549284 450043193 819.3 non-object
421
+ // 33305 1813840 54.5 CFString ObjC CoreFoundation
422
+ const headerIdx = out.search(/^\s*COUNT\s+BYTES\s+AVG\s+CLASS_NAME/m);
423
+ if (headerIdx === -1)
424
+ return { classes, totals };
425
+ const body = out.slice(headerIdx).split('\n').slice(2); // skip header + ===
426
+ for (const raw of body) {
427
+ const line = raw.trimEnd();
428
+ const trimmed = line.trim();
429
+ if (!trimmed)
430
+ break; // blank line ends the table
431
+ if (trimmed.startsWith('='))
432
+ continue;
433
+ // Match: leading count, bytes, avg, then the rest. CLASS_NAME / TYPE / BINARY
434
+ // are space-separated but the class name itself can contain spaces and angle
435
+ // brackets, so split off the trailing TYPE+BINARY columns from the right.
436
+ const m = line.match(/^\s*(\d+)\s+(\d+)\s+([\d.]+)\s+(.*)$/);
437
+ if (!m)
438
+ continue;
439
+ const count = Number(m[1]);
440
+ const bytes = Number(m[2]);
441
+ const rest = m[4];
442
+ // Trailing two whitespace-separated tokens are TYPE and BINARY (BINARY may
443
+ // be missing for "non-object" entries). Detect by looking at the last 1-2
444
+ // tokens; if the last token looks like a known TYPE, BINARY is absent.
445
+ const KNOWN_TYPES = new Set(['ObjC', 'Swift', 'C', 'C++', 'CFType']);
446
+ let className = rest;
447
+ let type;
448
+ let binary;
449
+ // Try: "<class> TYPE BINARY"
450
+ const trail2 = rest.match(/^(.*?)\s{2,}(\S+)\s+(\S+)\s*$/);
451
+ if (trail2 && KNOWN_TYPES.has(trail2[2])) {
452
+ className = trail2[1].trim();
453
+ type = trail2[2];
454
+ binary = trail2[3];
455
+ }
456
+ else {
457
+ // Try: "<class> TYPE" (no binary)
458
+ const trail1 = rest.match(/^(.*?)\s{2,}(\S+)\s*$/);
459
+ if (trail1 && KNOWN_TYPES.has(trail1[2])) {
460
+ className = trail1[1].trim();
461
+ type = trail1[2];
462
+ }
463
+ else {
464
+ className = rest.trim();
465
+ }
466
+ }
467
+ if (!className)
468
+ continue;
469
+ classes.push({ class: className, count, bytes, type, binary });
470
+ }
471
+ classes.sort((a, b) => b.bytes - a.bytes);
472
+ return { classes, totals };
473
+ }
474
+ // ── iOS leaks ─────────────────────────────────────────────────────────────────
475
+ function parseLeaks(out) {
476
+ // `leaks` prints e.g.:
477
+ // Process 12345: 42 leaks for 16384 total leaked bytes.
478
+ // Followed by per-leak detail lines:
479
+ // Leak: 0x12345 size=128 zone: ... Class: NSConcreteData
480
+ // We aggregate by class.
481
+ const summary = out.match(/(\d+)\s+leaks?\s+for\s+(\d+)\s+total\s+leaked\s+bytes/i);
482
+ const totalCount = summary ? Number(summary[1]) : 0;
483
+ const totalBytes = summary ? Number(summary[2]) : 0;
484
+ const byClass = new Map();
485
+ const re = /Leak:\s*0x[0-9a-f]+\s+size=(\d+)[^\n]*?(?:\s(?:Class|Type):\s*(\S+))?/gi;
486
+ let m;
487
+ while ((m = re.exec(out)) !== null) {
488
+ const size = Number(m[1]);
489
+ const cls = m[2] ?? '<unknown>';
490
+ const prev = byClass.get(cls) ?? { count: 0, bytes: 0 };
491
+ prev.count += 1;
492
+ prev.bytes += size;
493
+ byClass.set(cls, prev);
494
+ }
495
+ const classes = [...byClass.entries()]
496
+ .map(([cls, v]) => ({ class: cls, count: v.count, bytes: v.bytes }))
497
+ .sort((a, b) => b.bytes - a.bytes);
498
+ return { totalCount, totalBytes, classes };
499
+ }
500
+ async function collectIOS(deviceId, platform, appId, opts) {
247
501
  const report = { platform, deviceId, appId, notes: [] };
248
- // System-wide memory: vm_stat from inside the simulator (== host RAM).
249
- const vm = await (0, runner_js_1.spawnCommand)('xcrun', ['simctl', 'spawn', deviceId, 'vm_stat']);
502
+ // System-wide memory: vm_stat from the host. Simulators share host RAM, and
503
+ // `vm_stat` is a host macOS binary — it isn't present inside the simulator
504
+ // runtime, so `xcrun simctl spawn <id> vm_stat` fails with ENOENT.
505
+ const vm = await (0, runner_js_1.spawnCommand)('vm_stat', []);
250
506
  if (vm.success) {
251
507
  const sys = parseVmStat(vm.stdout);
252
508
  report.system = {
@@ -286,7 +542,19 @@ async function collectIOS(deviceId, platform, appId) {
286
542
  };
287
543
  }
288
544
  }
289
- // vmmap -summary for region breakdown slower but provides categories.
545
+ // footprint phys footprint + dirty memory totals. This is the actionable
546
+ // OOM number on iOS (jetsam compares this against the per-app limit), so
547
+ // surface it before vmmap region detail.
548
+ const fp = await (0, runner_js_1.spawnCommand)('footprint', [String(pid)]);
549
+ if (fp.success) {
550
+ const { footprintBytes, dirtyBytes } = parseFootprint(fp.stdout);
551
+ report.app = {
552
+ ...(report.app ?? {}),
553
+ ...(footprintBytes !== undefined ? { footprintBytes } : {}),
554
+ ...(dirtyBytes !== undefined ? { dirtyBytes } : {}),
555
+ };
556
+ }
557
+ // vmmap -summary for region breakdown.
290
558
  const vmmap = await (0, runner_js_1.spawnCommand)('vmmap', ['-summary', String(pid)]);
291
559
  if (vmmap.success) {
292
560
  const { app, regions } = parseVmmapSummary(vmmap.stdout);
@@ -297,14 +565,91 @@ async function collectIOS(deviceId, platform, appId) {
297
565
  };
298
566
  }
299
567
  else {
300
- report.notes.push('vmmap unavailable run `sudo DevToolsSecurity --enable` if it errors.');
568
+ report.notes.push(`vmmap unavailable: ${vmmap.stderr.trim() || 'unknown error'}`);
569
+ }
570
+ // heap for per-class object counts/bytes.
571
+ if (opts.objects) {
572
+ const h = await (0, runner_js_1.spawnCommand)('heap', [String(pid)]);
573
+ if (h.success) {
574
+ const { classes, totals } = parseHeap(h.stdout);
575
+ if (classes.length > 0)
576
+ report.objectClasses = classes;
577
+ if (totals)
578
+ report.heapTotals = totals;
579
+ }
580
+ else {
581
+ report.notes.push(`heap unavailable: ${h.stderr.trim() || 'unknown error'}`);
582
+ }
583
+ }
584
+ // leaks for leak detection.
585
+ if (opts.leaks) {
586
+ const l = await (0, runner_js_1.spawnCommand)('leaks', [String(pid)]);
587
+ // `leaks` exits non-zero when leaks are found; treat any output as success.
588
+ if (l.stdout && l.stdout.includes('leaks for')) {
589
+ report.leaks = parseLeaks(l.stdout);
590
+ }
591
+ else if (l.success) {
592
+ report.leaks = { totalCount: 0, totalBytes: 0, classes: [] };
593
+ }
594
+ else {
595
+ report.notes.push(`leaks unavailable: ${l.stderr.trim() || 'unknown error'}`);
596
+ }
301
597
  }
302
598
  if (report.notes.length === 0)
303
599
  delete report.notes;
304
600
  return report;
305
601
  }
306
- // ── Web (Playwright via CDP) ──────────────────────────────────────────────────
307
- async function collectWeb(deviceId, sessionName) {
602
+ function parseV8HeapSnapshot(snap) {
603
+ const meta = snap.snapshot.meta;
604
+ const nf = meta.node_fields;
605
+ const stride = nf.length;
606
+ const typeIdx = nf.indexOf('type');
607
+ const nameIdx = nf.indexOf('name');
608
+ const sizeIdx = nf.indexOf('self_size');
609
+ const typeNames = meta.node_types[0]; // e.g. ["hidden","array","string","object","code","closure","regexp","number","native","synthetic","concatenated string","sliced string","symbol","bigint"]
610
+ const nodes = snap.nodes;
611
+ const strings = snap.strings;
612
+ // For "object", "closure", "native" → name is the constructor / function /
613
+ // C++ class name. For other types, group under "<type>".
614
+ const namedTypes = new Set(['object', 'closure', 'native']);
615
+ const namedTypeIds = new Set();
616
+ typeNames.forEach((t, i) => {
617
+ if (namedTypes.has(t))
618
+ namedTypeIds.add(i);
619
+ });
620
+ const byClass = new Map();
621
+ let totalCount = 0;
622
+ let totalBytes = 0;
623
+ for (let i = 0; i < nodes.length; i += stride) {
624
+ const t = nodes[i + typeIdx];
625
+ const size = nodes[i + sizeIdx];
626
+ const nameId = nodes[i + nameIdx];
627
+ totalCount++;
628
+ totalBytes += size;
629
+ let label;
630
+ if (namedTypeIds.has(t)) {
631
+ const tName = typeNames[t];
632
+ const ctor = strings[nameId] || '<anonymous>';
633
+ label = tName === 'object' ? ctor : `${ctor} [${tName}]`;
634
+ }
635
+ else {
636
+ label = `<${typeNames[t] ?? 'unknown'}>`;
637
+ }
638
+ const prev = byClass.get(label);
639
+ if (prev) {
640
+ prev.count++;
641
+ prev.bytes += size;
642
+ }
643
+ else {
644
+ byClass.set(label, { count: 1, bytes: size });
645
+ }
646
+ }
647
+ const classes = [...byClass.entries()]
648
+ .map(([cls, v]) => ({ class: cls, count: v.count, bytes: v.bytes }))
649
+ .sort((a, b) => b.bytes - a.bytes);
650
+ return { classes, totals: { count: totalCount, bytes: totalBytes } };
651
+ }
652
+ async function collectWeb(deviceId, sessionName, opts) {
308
653
  const report = { platform: 'web', deviceId, notes: [] };
309
654
  let driver;
310
655
  try {
@@ -332,11 +677,8 @@ async function collectWeb(deviceId, sessionName) {
332
677
  const m = data.metrics;
333
678
  const pm = data.pageMemory;
334
679
  report.app = {
335
- // Heap totals — prefer page-context performance.memory (Chromium) over
336
- // CDP's JSHeapUsedSize (which can lag). Both are JS heap only.
337
680
  nativeHeapBytes: pm?.usedJSHeapSize ?? m['JSHeapUsedSize'],
338
681
  codeBytes: m['JSHeapTotalSize'] ? m['JSHeapTotalSize'] - (m['JSHeapUsedSize'] ?? 0) : undefined,
339
- // Roll the raw CDP metrics into `regions` so they're inspectable verbatim.
340
682
  regions: { ...m },
341
683
  };
342
684
  if (pm) {
@@ -347,7 +689,6 @@ async function collectWeb(deviceId, sessionName) {
347
689
  'JS Heap Limit': pm.jsHeapSizeLimit,
348
690
  };
349
691
  }
350
- // Object counts — direct CDP equivalents of Android's "Views/Activities/Binders".
351
692
  const objectKeys = [
352
693
  'Nodes',
353
694
  'Documents',
@@ -363,31 +704,228 @@ async function collectWeb(deviceId, sessionName) {
363
704
  }
364
705
  if (Object.keys(objects).length > 0)
365
706
  report.objects = objects;
707
+ // --objects → take a real V8 heap snapshot, parse class counts/bytes, and
708
+ // save the .heapsnapshot file so it can be opened in Chrome DevTools.
709
+ if (opts.objects) {
710
+ try {
711
+ const snapText = await driver.heapSnapshot({ gc: opts.gc !== false });
712
+ const snap = JSON.parse(snapText);
713
+ const { classes, totals } = parseV8HeapSnapshot(snap);
714
+ if (classes.length > 0)
715
+ report.objectClasses = classes;
716
+ report.heapTotals = totals;
717
+ const dumpsDir = node_path_1.default.join(node_os_1.default.homedir(), '.conductor', 'heap-dumps');
718
+ await node_fs_1.promises.mkdir(dumpsDir, { recursive: true });
719
+ const safeUrl = (data.url || 'page').replace(/[^\w.-]+/g, '_').slice(0, 60);
720
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
721
+ const file = node_path_1.default.join(dumpsDir, `${safeUrl}-${ts}.heapsnapshot`);
722
+ await node_fs_1.promises.writeFile(file, snapText);
723
+ report.notes.push(`Heap snapshot saved → ${file} (open in Chrome DevTools: Memory → Load profile)`);
724
+ }
725
+ catch (err) {
726
+ report.notes.push(`Heap snapshot failed: ${err instanceof Error ? err.message : String(err)}`);
727
+ }
728
+ }
366
729
  report.notes.push('Web memory is per-page (Performance.getMetrics + performance.memory). Run-wide RSS for the browser process is not exposed via CDP.');
367
730
  if (report.notes.length === 0)
368
731
  delete report.notes;
369
732
  return report;
370
733
  }
734
+ // ── Snapshot save / load / diff ───────────────────────────────────────────────
735
+ function snapshotsDir() {
736
+ return node_path_1.default.join(node_os_1.default.homedir(), '.conductor', 'memory-snapshots');
737
+ }
738
+ function snapshotPath(name) {
739
+ // Allow callers to pass either a bare name or a filename / path.
740
+ if (name.endsWith('.json') || name.includes('/'))
741
+ return name;
742
+ return node_path_1.default.join(snapshotsDir(), `${name}.json`);
743
+ }
744
+ async function saveSnapshot(name, report) {
745
+ const dir = snapshotsDir();
746
+ await node_fs_1.promises.mkdir(dir, { recursive: true });
747
+ const file = snapshotPath(name);
748
+ await node_fs_1.promises.writeFile(file, JSON.stringify(report, null, 2));
749
+ return file;
750
+ }
751
+ async function loadSnapshot(name) {
752
+ const file = snapshotPath(name);
753
+ const raw = await node_fs_1.promises.readFile(file, 'utf8');
754
+ return JSON.parse(raw);
755
+ }
756
+ async function listSnapshots() {
757
+ const dir = snapshotsDir();
758
+ let files;
759
+ try {
760
+ files = await node_fs_1.promises.readdir(dir);
761
+ }
762
+ catch {
763
+ return [];
764
+ }
765
+ const out = [];
766
+ for (const f of files) {
767
+ if (!f.endsWith('.json'))
768
+ continue;
769
+ const file = node_path_1.default.join(dir, f);
770
+ try {
771
+ const stat = await node_fs_1.promises.stat(file);
772
+ const r = JSON.parse(await node_fs_1.promises.readFile(file, 'utf8'));
773
+ out.push({
774
+ name: f.replace(/\.json$/, ''),
775
+ capturedAt: r.capturedAt,
776
+ appId: r.appId,
777
+ platform: r.platform,
778
+ size: stat.size,
779
+ });
780
+ }
781
+ catch {
782
+ /* skip malformed */
783
+ }
784
+ }
785
+ out.sort((a, b) => (a.capturedAt ?? '').localeCompare(b.capturedAt ?? ''));
786
+ return out;
787
+ }
788
+ function diffNumberMap(a, b) {
789
+ const keys = new Set([...Object.keys(a ?? {}), ...Object.keys(b ?? {})]);
790
+ const out = [];
791
+ for (const k of keys) {
792
+ const before = a?.[k] ?? 0;
793
+ const after = b?.[k] ?? 0;
794
+ if (before === after)
795
+ continue;
796
+ out.push({ key: k, before, after, delta: after - before });
797
+ }
798
+ out.sort((x, y) => Math.abs(y.delta) - Math.abs(x.delta));
799
+ return out;
800
+ }
801
+ function diffClasses(a, b) {
802
+ const idx = (arr) => {
803
+ const m = new Map();
804
+ for (const c of arr ?? [])
805
+ m.set(c.class, c);
806
+ return m;
807
+ };
808
+ const A = idx(a);
809
+ const B = idx(b);
810
+ const keys = new Set([...A.keys(), ...B.keys()]);
811
+ const out = [];
812
+ for (const k of keys) {
813
+ const x = A.get(k);
814
+ const y = B.get(k);
815
+ const beforeCount = x?.count ?? 0;
816
+ const afterCount = y?.count ?? 0;
817
+ const beforeBytes = x?.bytes ?? 0;
818
+ const afterBytes = y?.bytes ?? 0;
819
+ if (beforeCount === afterCount && beforeBytes === afterBytes)
820
+ continue;
821
+ out.push({
822
+ class: k,
823
+ beforeCount,
824
+ afterCount,
825
+ beforeBytes,
826
+ afterBytes,
827
+ deltaCount: afterCount - beforeCount,
828
+ deltaBytes: afterBytes - beforeBytes,
829
+ });
830
+ }
831
+ out.sort((x, y) => Math.abs(y.deltaBytes) - Math.abs(x.deltaBytes));
832
+ return out;
833
+ }
834
+ function buildDiff(a, b, aLabel, bLabel) {
835
+ const numericApp = (r) => {
836
+ const out = {};
837
+ for (const [k, v] of Object.entries(r.app ?? {})) {
838
+ if (typeof v === 'number')
839
+ out[k] = v;
840
+ }
841
+ return out;
842
+ };
843
+ return {
844
+ before: { name: aLabel, capturedAt: a.capturedAt },
845
+ after: { name: bLabel, capturedAt: b.capturedAt },
846
+ app: diffNumberMap(numericApp(a), numericApp(b)),
847
+ regions: diffNumberMap(a.app?.regions, b.app?.regions),
848
+ objects: diffNumberMap(a.objects, b.objects),
849
+ objectClasses: diffClasses(a.objectClasses, b.objectClasses),
850
+ leaksDelta: a.leaks || b.leaks
851
+ ? {
852
+ count: (b.leaks?.totalCount ?? 0) - (a.leaks?.totalCount ?? 0),
853
+ bytes: (b.leaks?.totalBytes ?? 0) - (a.leaks?.totalBytes ?? 0),
854
+ }
855
+ : undefined,
856
+ };
857
+ }
371
858
  // ── Formatting ────────────────────────────────────────────────────────────────
372
859
  function fmtBytes(n) {
373
860
  if (n === undefined)
374
861
  return '—';
375
- if (n < 1024)
376
- return `${n} B`;
862
+ const sign = n < 0 ? '-' : '';
863
+ const abs = Math.abs(n);
864
+ if (abs < 1024)
865
+ return `${sign}${abs} B`;
377
866
  const units = ['KB', 'MB', 'GB', 'TB'];
378
- let v = n / 1024;
867
+ let v = abs / 1024;
379
868
  let i = 0;
380
869
  while (v >= 1024 && i < units.length - 1) {
381
870
  v /= 1024;
382
871
  i++;
383
872
  }
384
- return `${v.toFixed(v >= 100 ? 0 : v >= 10 ? 1 : 2)} ${units[i]}`;
873
+ return `${sign}${v.toFixed(v >= 100 ? 0 : v >= 10 ? 1 : 2)} ${units[i]}`;
385
874
  }
386
- function formatReport(r) {
875
+ function fmtSignedBytes(n) {
876
+ if (n === 0)
877
+ return '±0';
878
+ return (n > 0 ? '+' : '') + fmtBytes(n);
879
+ }
880
+ function fmtSignedInt(n) {
881
+ if (n === 0)
882
+ return '±0';
883
+ return (n > 0 ? '+' : '') + String(n);
884
+ }
885
+ function makeFilter(pattern) {
886
+ if (!pattern)
887
+ return undefined;
888
+ try {
889
+ const re = new RegExp(pattern, 'i');
890
+ return (s) => re.test(s);
891
+ }
892
+ catch {
893
+ // Invalid regex → no filter (safer than throwing mid-render).
894
+ return undefined;
895
+ }
896
+ }
897
+ function filterClasses(arr, pattern) {
898
+ const f = makeFilter(pattern);
899
+ return f ? arr.filter((c) => f(c.class)) : arr;
900
+ }
901
+ function filterEntries(arr, pattern, growthOnly) {
902
+ const f = makeFilter(pattern);
903
+ return arr.filter((e) => {
904
+ if (growthOnly && e.delta <= 0)
905
+ return false;
906
+ if (f && !f(e.key))
907
+ return false;
908
+ return true;
909
+ });
910
+ }
911
+ function filterClassDiffs(arr, pattern, growthOnly) {
912
+ const f = makeFilter(pattern);
913
+ return arr.filter((c) => {
914
+ if (growthOnly && c.deltaBytes <= 0)
915
+ return false;
916
+ if (f && !f(c.class))
917
+ return false;
918
+ return true;
919
+ });
920
+ }
921
+ function formatReport(r, opts = {}) {
922
+ const top = opts.top ?? 20;
387
923
  const lines = [];
388
924
  lines.push(`Device: ${r.deviceId} (${r.platform})`);
389
925
  if (r.appId)
390
926
  lines.push(`App: ${r.appId}${r.pid ? ` (pid ${r.pid})` : ''}`);
927
+ if (r.capturedAt)
928
+ lines.push(`Captured: ${r.capturedAt}`);
391
929
  if (r.system) {
392
930
  lines.push('');
393
931
  lines.push('System memory:');
@@ -409,6 +947,10 @@ function formatReport(r) {
409
947
  lines.push('');
410
948
  lines.push('App memory:');
411
949
  const a = r.app;
950
+ if (a.footprintBytes !== undefined)
951
+ lines.push(` Footprint: ${fmtBytes(a.footprintBytes)} (jetsam target on iOS)`);
952
+ if (a.dirtyBytes !== undefined)
953
+ lines.push(` Dirty: ${fmtBytes(a.dirtyBytes)}`);
412
954
  if (a.totalPssBytes !== undefined)
413
955
  lines.push(` PSS total: ${fmtBytes(a.totalPssBytes)}`);
414
956
  if (a.totalRssBytes !== undefined)
@@ -433,24 +975,46 @@ function formatReport(r) {
433
975
  lines.push(` System: ${fmtBytes(a.systemBytes)}`);
434
976
  if (a.regions && Object.keys(a.regions).length > 0) {
435
977
  lines.push('');
436
- lines.push('Top memory regions (resident):');
978
+ lines.push(`Top memory regions (resident, top ${top}):`);
437
979
  const entries = Object.entries(a.regions)
438
980
  .filter(([, v]) => v > 0)
439
- .sort((a, b) => b[1] - a[1])
440
- .slice(0, 12);
981
+ .sort((x, y) => y[1] - x[1])
982
+ .slice(0, top);
441
983
  for (const [name, bytes] of entries) {
442
- lines.push(` ${name.padEnd(28)} ${fmtBytes(bytes)}`);
984
+ lines.push(` ${name.padEnd(30)} ${fmtBytes(bytes)}`);
443
985
  }
444
986
  }
445
987
  }
988
+ if (r.objectClasses && r.objectClasses.length > 0) {
989
+ const filtered = filterClasses(r.objectClasses, opts.filter);
990
+ lines.push('');
991
+ if (r.heapTotals) {
992
+ lines.push(`Heap objects (${r.heapTotals.count.toLocaleString()} nodes, ${fmtBytes(r.heapTotals.bytes)} total) — top ${top} by bytes${opts.filter ? ` (filter: /${opts.filter}/)` : ''}:`);
993
+ }
994
+ else {
995
+ lines.push(`Heap objects (top ${top} by bytes${opts.filter ? ` (filter: /${opts.filter}/)` : ''}):`);
996
+ }
997
+ lines.push(` ${'COUNT'.padStart(8)} ${'BYTES'.padStart(10)} CLASS`);
998
+ for (const c of filtered.slice(0, top)) {
999
+ const name = c.binary ? `${c.class} [${c.binary}]` : c.class;
1000
+ lines.push(` ${String(c.count).padStart(8)} ${fmtBytes(c.bytes).padStart(10)} ${name}`);
1001
+ }
1002
+ }
446
1003
  if (r.objects && Object.keys(r.objects).length > 0) {
447
1004
  lines.push('');
448
1005
  lines.push('Object counts:');
449
- const entries = Object.entries(r.objects).sort((a, b) => b[1] - a[1]);
1006
+ const entries = Object.entries(r.objects).sort((x, y) => y[1] - x[1]);
450
1007
  for (const [name, count] of entries) {
451
1008
  lines.push(` ${name.padEnd(20)} ${count}`);
452
1009
  }
453
1010
  }
1011
+ if (r.leaks) {
1012
+ lines.push('');
1013
+ lines.push(`Leaks: ${r.leaks.totalCount} leak${r.leaks.totalCount === 1 ? '' : 's'} (${fmtBytes(r.leaks.totalBytes)})`);
1014
+ for (const l of r.leaks.classes.slice(0, top)) {
1015
+ lines.push(` ${String(l.count).padStart(6)} ${fmtBytes(l.bytes).padStart(10)} ${l.class}`);
1016
+ }
1017
+ }
454
1018
  if (r.notes && r.notes.length > 0) {
455
1019
  lines.push('');
456
1020
  for (const n of r.notes)
@@ -458,8 +1022,94 @@ function formatReport(r) {
458
1022
  }
459
1023
  return lines.join('\n');
460
1024
  }
1025
+ function formatDiff(d, opts = {}) {
1026
+ const top = opts.top ?? 20;
1027
+ const lines = [];
1028
+ const tag = [];
1029
+ if (opts.growthOnly)
1030
+ tag.push('growth-only');
1031
+ if (opts.filter)
1032
+ tag.push(`filter: /${opts.filter}/`);
1033
+ const tagStr = tag.length > 0 ? ` [${tag.join(', ')}]` : '';
1034
+ lines.push(`Diff: ${d.before.name} → ${d.after.name}${tagStr}`);
1035
+ if (d.before.capturedAt || d.after.capturedAt) {
1036
+ lines.push(` ${d.before.capturedAt ?? '?'} → ${d.after.capturedAt ?? '?'}`);
1037
+ }
1038
+ // App memory deltas — only filtered by growth-only (key is e.g. "totalRssBytes",
1039
+ // not user-meaningful for regex filtering).
1040
+ const appRows = opts.growthOnly ? d.app.filter((e) => e.delta > 0) : d.app;
1041
+ if (appRows.length > 0) {
1042
+ lines.push('');
1043
+ lines.push('App memory deltas:');
1044
+ for (const e of appRows.slice(0, top)) {
1045
+ lines.push(` ${e.key.padEnd(20)} ${fmtBytes(e.before).padStart(10)} → ${fmtBytes(e.after).padStart(10)} (${fmtSignedBytes(e.delta)})`);
1046
+ }
1047
+ }
1048
+ const regionRows = filterEntries(d.regions, opts.filter, opts.growthOnly);
1049
+ if (regionRows.length > 0) {
1050
+ lines.push('');
1051
+ lines.push(`Region deltas (top ${top} by |Δ|):`);
1052
+ for (const e of regionRows.slice(0, top)) {
1053
+ lines.push(` ${e.key.padEnd(30)} ${fmtBytes(e.before).padStart(10)} → ${fmtBytes(e.after).padStart(10)} (${fmtSignedBytes(e.delta)})`);
1054
+ }
1055
+ }
1056
+ const classRows = filterClassDiffs(d.objectClasses, opts.filter, opts.growthOnly);
1057
+ if (classRows.length > 0) {
1058
+ lines.push('');
1059
+ lines.push(`Class deltas (top ${top} by |Δ bytes|):`);
1060
+ lines.push(` ${'Δ COUNT'.padStart(8)} ${'Δ BYTES'.padStart(10)} ${'AFTER COUNT'.padStart(11)} ${'AFTER BYTES'.padStart(11)} CLASS`);
1061
+ for (const c of classRows.slice(0, top)) {
1062
+ lines.push(` ${fmtSignedInt(c.deltaCount).padStart(8)} ${fmtSignedBytes(c.deltaBytes).padStart(10)} ${String(c.afterCount).padStart(11)} ${fmtBytes(c.afterBytes).padStart(11)} ${c.class}`);
1063
+ }
1064
+ }
1065
+ const objRows = filterEntries(d.objects, opts.filter, opts.growthOnly);
1066
+ if (objRows.length > 0) {
1067
+ lines.push('');
1068
+ lines.push('Object count deltas:');
1069
+ for (const e of objRows.slice(0, top)) {
1070
+ lines.push(` ${e.key.padEnd(20)} ${String(e.before).padStart(8)} → ${String(e.after).padStart(8)} (${fmtSignedInt(e.delta)})`);
1071
+ }
1072
+ }
1073
+ if (d.leaksDelta && (d.leaksDelta.count !== 0 || d.leaksDelta.bytes !== 0)) {
1074
+ if (!opts.growthOnly || d.leaksDelta.bytes > 0) {
1075
+ lines.push('');
1076
+ lines.push(`Leaks delta: ${fmtSignedInt(d.leaksDelta.count)} leak(s), ${fmtSignedBytes(d.leaksDelta.bytes)}`);
1077
+ }
1078
+ }
1079
+ if (lines.length <= 2)
1080
+ lines.push('', 'No differences detected.');
1081
+ return lines.join('\n');
1082
+ }
461
1083
  // ── Entry point ───────────────────────────────────────────────────────────────
462
- async function memory(appIdArg, opts = {}, sessionName = 'default', _memOpts = {}) {
1084
+ async function memory(appIdArg, opts = {}, sessionName = 'default', memOpts = {}) {
1085
+ // List snapshots — no device required.
1086
+ if (memOpts.listSnapshots) {
1087
+ const list = await listSnapshots();
1088
+ if (opts.json) {
1089
+ (0, output_js_1.printData)({ status: 'ok', snapshots: list }, opts);
1090
+ }
1091
+ else if (list.length === 0) {
1092
+ console.log(`No snapshots saved. Try: conductor memory --save baseline`);
1093
+ }
1094
+ else {
1095
+ console.log(`Snapshots in ${snapshotsDir()}:`);
1096
+ for (const s of list) {
1097
+ console.log(` ${s.name.padEnd(24)} ${(s.platform ?? '?').padEnd(8)} ${s.appId ?? ''} ${s.capturedAt ?? ''}`);
1098
+ }
1099
+ }
1100
+ return 0;
1101
+ }
1102
+ // Diff between two saved snapshots.
1103
+ if (memOpts.diff && memOpts.diffOther) {
1104
+ const a = await loadSnapshot(memOpts.diff);
1105
+ const b = await loadSnapshot(memOpts.diffOther);
1106
+ const d = buildDiff(a, b, memOpts.diff, memOpts.diffOther);
1107
+ if (opts.json)
1108
+ (0, output_js_1.printData)({ status: 'ok', diff: d }, opts);
1109
+ else
1110
+ console.log(formatDiff(d, memOpts));
1111
+ return 0;
1112
+ }
463
1113
  const deviceId = await resolveDeviceId(sessionName);
464
1114
  if (!deviceId) {
465
1115
  (0, output_js_1.printError)('No device found. Connect a device or start a simulator first.', opts);
@@ -468,22 +1118,44 @@ async function memory(appIdArg, opts = {}, sessionName = 'default', _memOpts = {
468
1118
  const platform = await (0, bootstrap_js_1.detectPlatform)(deviceId);
469
1119
  let report;
470
1120
  if (platform === 'web') {
471
- report = await collectWeb(deviceId, sessionName);
1121
+ report = await collectWeb(deviceId, sessionName, memOpts);
472
1122
  }
473
1123
  else {
474
- const appId = await resolveAppId(appIdArg, sessionName, deviceId);
1124
+ const appId = await resolveAppId(appIdArg, sessionName, deviceId, platform);
475
1125
  if (platform === 'android') {
476
- report = await collectAndroid(deviceId, appId);
1126
+ report = await collectAndroid(deviceId, appId, memOpts);
477
1127
  }
478
1128
  else {
479
- report = await collectIOS(deviceId, platform, appId);
1129
+ report = await collectIOS(deviceId, platform, appId, memOpts);
1130
+ }
1131
+ if (!appId) {
1132
+ report.notes ?? (report.notes = []);
1133
+ report.notes.push('No foreground app detected — pass an app id (e.g. `conductor memory com.example.app --all`) to get per-app memory, objects, and leaks.');
480
1134
  }
481
1135
  }
1136
+ report.capturedAt = new Date().toISOString();
1137
+ // Diff current report against a saved snapshot.
1138
+ if (memOpts.diff) {
1139
+ const a = await loadSnapshot(memOpts.diff);
1140
+ const d = buildDiff(a, report, memOpts.diff, 'current');
1141
+ if (memOpts.save)
1142
+ await saveSnapshot(memOpts.save, report);
1143
+ if (opts.json)
1144
+ (0, output_js_1.printData)({ status: 'ok', diff: d, current: report }, opts);
1145
+ else
1146
+ console.log(formatDiff(d, memOpts));
1147
+ return 0;
1148
+ }
1149
+ if (memOpts.save) {
1150
+ const file = await saveSnapshot(memOpts.save, report);
1151
+ if (!opts.json)
1152
+ console.error(`Saved snapshot → ${file}`);
1153
+ }
482
1154
  if (opts.json) {
483
1155
  (0, output_js_1.printData)({ status: 'ok', ...report }, opts);
484
1156
  }
485
1157
  else {
486
- console.log(formatReport(report));
1158
+ console.log(formatReport(report, memOpts));
487
1159
  }
488
1160
  return 0;
489
1161
  }