@houwert/conductor 0.28.0 → 0.29.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.
@@ -4,15 +4,58 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.HELP = void 0;
7
+ exports.aggregateRuns = aggregateRuns;
7
8
  exports.runFlow = runFlow;
8
9
  exports.HELP = ` run-flow <file> [--device <id>] Run a Maestro YAML flow file
9
10
  --env KEY=VALUE Inject env var (repeatable; overrides flow env block)
10
- --benchmark Print elapsed time for each command and total flow time`;
11
+ --benchmark Print elapsed time for each command and total flow time
12
+ --repeat <n> With --benchmark, run the flow n times and report p50/p90/σ
13
+ --json With --benchmark --repeat, emit the aggregate as JSON`;
11
14
  const path_1 = __importDefault(require("path"));
12
15
  const runner_js_1 = require("../runner.js");
13
16
  const flow_runner_js_1 = require("../drivers/flow-runner.js");
14
17
  const output_js_1 = require("../output.js");
15
- async function runFlow(file, opts = {}, sessionName = 'default', env = {}, benchmark = false) {
18
+ const stats_js_1 = require("../stats.js");
19
+ /**
20
+ * Aggregate per-run timings by command label. Keyed on label rather than
21
+ * position so a flow whose branches differ between runs still lines up.
22
+ */
23
+ function aggregateRuns(runs) {
24
+ const byLabel = new Map();
25
+ for (const run of runs) {
26
+ for (const entry of run.entries) {
27
+ const slot = byLabel.get(entry.label) ?? { times: [], failures: 0 };
28
+ slot.times.push(entry.ms);
29
+ if (!entry.ok)
30
+ slot.failures++;
31
+ byLabel.set(entry.label, slot);
32
+ }
33
+ }
34
+ return {
35
+ runs: runs.length,
36
+ total: (0, stats_js_1.describe)(runs.map((r) => r.totalMs)),
37
+ commands: [...byLabel.entries()]
38
+ .map(([label, v]) => ({
39
+ label,
40
+ runs: v.times.length,
41
+ failures: v.failures,
42
+ ...(0, stats_js_1.describe)(v.times),
43
+ }))
44
+ .sort((a, b) => (b.p50Ms ?? 0) - (a.p50Ms ?? 0)),
45
+ };
46
+ }
47
+ function printAggregate(agg) {
48
+ console.log(`\nBenchmark over ${agg.runs} run(s)`);
49
+ console.log(` total p50 ${(0, stats_js_1.fmt)(agg.total.p50Ms)} p90 ${(0, stats_js_1.fmt)(agg.total.p90Ms)} ` +
50
+ `σ ${(0, stats_js_1.fmt)(agg.total.stddevMs)} min ${(0, stats_js_1.fmt)(agg.total.minMs)} max ${(0, stats_js_1.fmt)(agg.total.maxMs)}`);
51
+ console.log(`\n ${'p50'.padStart(9)} ${'p90'.padStart(9)} ${'σ'.padStart(8)} n command`);
52
+ for (const c of agg.commands) {
53
+ console.log(` ${(0, stats_js_1.fmt)(c.p50Ms).padStart(9)} ${(0, stats_js_1.fmt)(c.p90Ms).padStart(9)} ` +
54
+ `${(0, stats_js_1.fmt)(c.stddevMs).padStart(8)} ${c.runs} ${c.label}` +
55
+ (c.failures > 0 ? ` (${c.failures} failed)` : ''));
56
+ }
57
+ }
58
+ async function runFlow(file, opts = {}, sessionName = 'default', env = {}, benchmark = false, repeat = 1) {
16
59
  if (!file) {
17
60
  (0, output_js_1.printError)('run-flow requires <file>', opts);
18
61
  return 1;
@@ -21,8 +64,31 @@ async function runFlow(file, opts = {}, sessionName = 'default', env = {}, bench
21
64
  try {
22
65
  const driver = await (0, runner_js_1.getDriver)(sessionName);
23
66
  const flow = await (0, flow_runner_js_1.parseFlowFile)(resolvedFile, env);
24
- await (0, flow_runner_js_1.executeFlow)(flow, driver, { cwd: path_1.default.dirname(resolvedFile), env, benchmark });
25
- (0, output_js_1.printSuccess)(`run-flow "${file}" done`, opts);
67
+ if (repeat <= 1) {
68
+ await (0, flow_runner_js_1.executeFlow)(flow, driver, { cwd: path_1.default.dirname(resolvedFile), env, benchmark });
69
+ (0, output_js_1.printSuccess)(`run-flow "${file}" — done`, opts);
70
+ return 0;
71
+ }
72
+ const runs = [];
73
+ for (let i = 0; i < repeat; i++) {
74
+ const entries = [];
75
+ console.log(`\n── run ${i + 1}/${repeat} ─────────────────────────────`);
76
+ const started = performance.now();
77
+ await (0, flow_runner_js_1.executeFlow)(flow, driver, {
78
+ cwd: path_1.default.dirname(resolvedFile),
79
+ env,
80
+ benchmark,
81
+ benchmarkSink: (e) => entries.push(e),
82
+ });
83
+ runs.push({ totalMs: (0, stats_js_1.round)(performance.now() - started), entries });
84
+ }
85
+ const agg = aggregateRuns(runs);
86
+ if (opts.json)
87
+ (0, output_js_1.printData)({ status: 'ok', benchmark: agg }, opts);
88
+ else {
89
+ printAggregate(agg);
90
+ (0, output_js_1.printSuccess)(`run-flow "${file}" — ${repeat} runs done`, opts);
91
+ }
26
92
  return 0;
27
93
  }
28
94
  catch (err) {
@@ -392,6 +392,7 @@ async function executeFlow(flow, driver, opts = {}) {
392
392
  output: opts.output ?? {},
393
393
  depth: opts.depth ?? 0,
394
394
  benchmark: opts.benchmark,
395
+ benchmarkSink: opts.benchmarkSink,
395
396
  };
396
397
  const flowStart = opts.benchmark ? perf_hooks_1.performance.now() : 0;
397
398
  if (flow.onFlowStart?.length) {
@@ -515,9 +516,11 @@ async function executeCommand(cmd, driver, opts) {
515
516
  else {
516
517
  process.stdout.write(`${indent}→ ${label} ... `);
517
518
  }
518
- const t0 = opts.benchmark ? perf_hooks_1.performance.now() : 0;
519
+ const timing = opts.benchmark || opts.benchmarkSink;
520
+ const t0 = timing ? perf_hooks_1.performance.now() : 0;
519
521
  try {
520
522
  await executeCommandBody(key, resolvedVal, driver, opts);
523
+ opts.benchmarkSink?.({ label, depth: opts.depth, ms: perf_hooks_1.performance.now() - t0, ok: true });
521
524
  const elapsed = opts.benchmark ? ` (${fmtMs(perf_hooks_1.performance.now() - t0)})` : '';
522
525
  if (!isCompound) {
523
526
  console.log(`ok${elapsed}`);
@@ -528,6 +531,7 @@ async function executeCommand(cmd, driver, opts) {
528
531
  }
529
532
  catch (err) {
530
533
  const msg = err instanceof Error ? err.message : String(err);
534
+ opts.benchmarkSink?.({ label, depth: opts.depth, ms: perf_hooks_1.performance.now() - t0, ok: false });
531
535
  const elapsed = opts.benchmark ? ` (${fmtMs(perf_hooks_1.performance.now() - t0)})` : '';
532
536
  if (optional) {
533
537
  // For compound commands the sub-command already printed its warning/failure
@@ -1273,6 +1277,7 @@ async function executeCommandBody(key, val, driver, opts) {
1273
1277
  output: opts.output,
1274
1278
  depth: childDepth,
1275
1279
  benchmark: opts.benchmark,
1280
+ benchmarkSink: opts.benchmarkSink,
1276
1281
  });
1277
1282
  }
1278
1283
  else {
@@ -1293,6 +1298,7 @@ async function executeCommandBody(key, val, driver, opts) {
1293
1298
  output: opts.output,
1294
1299
  depth: childDepth,
1295
1300
  benchmark: opts.benchmark,
1301
+ benchmarkSink: opts.benchmarkSink,
1296
1302
  });
1297
1303
  }
1298
1304
  }
@@ -6,6 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.MetroCdpClient = void 0;
7
7
  exports.selectDebuggerUrl = selectDebuggerUrl;
8
8
  exports.resolveDebuggerUrl = resolveDebuggerUrl;
9
+ exports.resolveMetroPort = resolveMetroPort;
9
10
  exports.cdpCall = cdpCall;
10
11
  /**
11
12
  * One-shot CDP client to Metro's debugger endpoint.
@@ -62,14 +63,38 @@ function selectDebuggerUrl(targets, opts, displayName) {
62
63
  * Throws with a clear message if Metro is unreachable or no target matches.
63
64
  */
64
65
  async function resolveDebuggerUrl(opts) {
65
- const port = opts.port ?? 8081;
66
+ const port = await resolveMetroPort(opts);
66
67
  const host = opts.host ?? 'localhost';
67
- const targets = await (0, metro_js_1.fetchTargets)(port, host);
68
+ let targets;
69
+ try {
70
+ targets = await (0, metro_js_1.fetchTargets)(port, host);
71
+ }
72
+ catch (err) {
73
+ const detail = err instanceof Error ? err.message : String(err);
74
+ throw new Error(`Cannot reach Metro on ${host}:${port} — ${detail}.\n` +
75
+ `Pass --port <n> if Metro listens elsewhere (a project may set RCT_METRO_PORT); ` +
76
+ `\`conductor workspace info\` reports the port it can see.`);
77
+ }
68
78
  let displayName;
69
79
  if (opts.deviceId && opts.platform) {
70
80
  displayName = (await (0, metro_discovery_js_1.getDeviceDisplayName)(opts.platform, opts.deviceId)) ?? undefined;
71
81
  }
72
- return selectDebuggerUrl(targets, opts, displayName);
82
+ return selectDebuggerUrl(targets, { ...opts, port }, displayName);
83
+ }
84
+ /**
85
+ * Metro's port for this device: the explicit --port, else whatever the device is
86
+ * actually connected to, else 8081. Auto-discovery matters because a project
87
+ * that sets RCT_METRO_PORT otherwise fails with an opaque connection error.
88
+ */
89
+ async function resolveMetroPort(opts) {
90
+ if (opts.port !== undefined)
91
+ return opts.port;
92
+ if (opts.deviceId && opts.platform) {
93
+ const discovered = await (0, metro_discovery_js_1.discoverMetroPortForDevice)(opts.platform, opts.deviceId).catch(() => null);
94
+ if (discovered)
95
+ return discovered;
96
+ }
97
+ return 8081;
73
98
  }
74
99
  /**
75
100
  * Open a short-lived CDP socket, send a single method, return the result.
package/dist/index.js CHANGED
@@ -42,6 +42,8 @@ const debug_js_1 = require("./commands/debug.js");
42
42
  const network_js_1 = require("./commands/network.js");
43
43
  const flow_record_js_1 = require("./commands/flow-record.js");
44
44
  const profile_js_1 = require("./commands/profile.js");
45
+ const profile_frames_js_1 = require("./commands/profile-frames.js");
46
+ const profile_js_js_1 = require("./commands/profile-js.js");
45
47
  const crashes_js_1 = require("./commands/crashes.js");
46
48
  const flow_recorder_js_1 = require("./drivers/flow-recorder.js");
47
49
  const foreground_app_js_1 = require("./commands/foreground-app.js");
@@ -61,7 +63,6 @@ const start_device_js_1 = require("./commands/start-device.js");
61
63
  const stop_device_js_1 = require("./commands/stop-device.js");
62
64
  const delete_device_js_1 = require("./commands/delete-device.js");
63
65
  const logs_js_1 = require("./commands/logs.js");
64
- const cases_js_1 = require("./commands/cases.js");
65
66
  const memory_js_1 = require("./commands/memory.js");
66
67
  const metro_js_1 = require("./commands/metro.js");
67
68
  const clipboard_js_1 = require("./commands/clipboard.js");
@@ -89,7 +90,6 @@ const COMMAND_HELP = {
89
90
  'list-devices': list_devices_js_1.HELP,
90
91
  'foreground-app': foreground_app_js_1.HELP,
91
92
  'list-apps': list_apps_js_1.HELP,
92
- cases: cases_js_1.HELP,
93
93
  'copy-app': copy_app_js_1.HELP,
94
94
  'download-app': download_app_js_1.HELP,
95
95
  'install-app': install_app_js_1.HELP,
@@ -225,6 +225,10 @@ async function main() {
225
225
  'force',
226
226
  'yes',
227
227
  'update',
228
+ 'measure',
229
+ 'report',
230
+ 'timeline',
231
+ 'baselines',
228
232
  ],
229
233
  string: [
230
234
  'device',
@@ -256,6 +260,9 @@ async function main() {
256
260
  'source',
257
261
  'level',
258
262
  'save',
263
+ 'save-baseline',
264
+ 'sequence',
265
+ 'settle',
259
266
  'diff',
260
267
  'vs',
261
268
  'top',
@@ -294,13 +301,6 @@ async function main() {
294
301
  'speed',
295
302
  'threshold',
296
303
  'reference',
297
- 'project',
298
- 'junit',
299
- 'verdict',
300
- 'note',
301
- 'column',
302
- 'build',
303
- 'environment',
304
304
  ],
305
305
  alias: { h: 'help', v: 'verbose', V: 'version', o: 'output', y: 'yes' },
306
306
  });
@@ -664,6 +664,15 @@ async function main() {
664
664
  exitCode = await (0, press_key_js_1.pressKey)(key, opts, sessionName, {
665
665
  longPress: argv['long-press'],
666
666
  duration: durationArg !== undefined ? Number(durationArg) : undefined,
667
+ measure: argv['measure'],
668
+ repeat: argv['repeat'] !== undefined ? Number(argv['repeat']) : undefined,
669
+ timeoutMs: argv['timeout'] !== undefined ? Number(argv['timeout']) : undefined,
670
+ pollIntervalMs: argv['poll-interval'] !== undefined ? Number(argv['poll-interval']) : undefined,
671
+ settleMs: argv['settle'] !== undefined ? Number(argv['settle']) : undefined,
672
+ sequence: typeof argv['sequence'] === 'string'
673
+ ? argv['sequence'].split(',')
674
+ : undefined,
675
+ appId: argv['app'],
667
676
  });
668
677
  break;
669
678
  }
@@ -878,35 +887,6 @@ async function main() {
878
887
  interval: argv['interval'] !== undefined ? Number(argv['interval']) : undefined,
879
888
  });
880
889
  break;
881
- case 'cases': {
882
- // Repo-scoped, not device-scoped: cases and their results are files.
883
- const root = argv['project'] ?? process.cwd();
884
- const sub = rest[0] ?? 'list';
885
- if (sub === 'list') {
886
- exitCode = await (0, cases_js_1.casesList)(root, opts);
887
- }
888
- else if (sub === 'report') {
889
- exitCode = await (0, cases_js_1.casesReport)(root, argv['junit'] ?? '', {
890
- ...opts,
891
- build: argv['build'],
892
- environment: argv['environment'],
893
- });
894
- }
895
- else if (sub === 'result') {
896
- exitCode = await (0, cases_js_1.casesResult)(root, rest[1] ?? '', argv['verdict'] ?? 'passed', {
897
- ...opts,
898
- note: argv['note'],
899
- column: argv['column'],
900
- build: argv['build'],
901
- environment: argv['environment'],
902
- });
903
- }
904
- else {
905
- console.error(`Unknown cases subcommand "${sub}". Use list, report or result.`);
906
- exitCode = 1;
907
- }
908
- break;
909
- }
910
890
  case 'logs':
911
891
  exitCode = await (0, logs_js_1.logs)(opts, sessionName, {
912
892
  source: argv['source'],
@@ -939,7 +919,7 @@ async function main() {
939
919
  const rawEnv = argv['env'];
940
920
  const envPairs = Array.isArray(rawEnv) ? rawEnv : rawEnv ? [rawEnv] : [];
941
921
  const flowEnv = Object.fromEntries(envPairs.map((e) => e.split('=', 2)));
942
- exitCode = await (0, run_flow_js_1.runFlow)(file, opts, sessionName, flowEnv, argv['benchmark']);
922
+ exitCode = await (0, run_flow_js_1.runFlow)(file, opts, sessionName, flowEnv, argv['benchmark'], argv['repeat'] !== undefined ? Number(argv['repeat']) : 1);
943
923
  break;
944
924
  }
945
925
  case 'run-flow-inline': {
@@ -1018,12 +998,15 @@ async function main() {
1018
998
  const sub = (rest[0] ?? '').toLowerCase();
1019
999
  const port = argv['port'] !== undefined ? Number(argv['port']) : undefined;
1020
1000
  const targetIndex = argv['target'] !== undefined ? Number(argv['target']) : undefined;
1001
+ const top = argv['top'] !== undefined ? Number(argv['top']) : undefined;
1021
1002
  if (sub === 'cpu') {
1022
1003
  const durationSec = argv['duration'] !== undefined ? Number(argv['duration']) : 10;
1023
1004
  exitCode = await (0, profile_js_1.profileCpu)(opts, sessionName, {
1024
1005
  durationSec,
1025
1006
  out: argv['out'],
1026
1007
  appId: rest[1],
1008
+ report: argv['report'],
1009
+ top,
1027
1010
  });
1028
1011
  }
1029
1012
  else if (sub === 'memory') {
@@ -1035,14 +1018,67 @@ async function main() {
1035
1018
  appId: rest[1],
1036
1019
  });
1037
1020
  }
1021
+ else if (sub === 'frames') {
1022
+ const sub2 = (rest[1] ?? '').toLowerCase();
1023
+ if (sub2 === 'reset') {
1024
+ exitCode = await (0, profile_frames_js_1.profileFramesReset)(opts, sessionName, rest[2]);
1025
+ }
1026
+ else if (sub2 === 'report') {
1027
+ exitCode = await (0, profile_frames_js_1.profileFramesReport)(opts, sessionName, {
1028
+ appId: rest[2],
1029
+ trackSec: argv['track'] !== undefined ? Number(argv['track']) : undefined,
1030
+ intervalMs: argv['interval'] !== undefined ? Number(argv['interval']) : undefined,
1031
+ top,
1032
+ saveBaseline: argv['save-baseline'],
1033
+ diff: argv['diff'],
1034
+ listBaselines: argv['baselines'],
1035
+ });
1036
+ }
1037
+ else {
1038
+ console.error('Usage: conductor profile frames <reset|report> [<appId>]');
1039
+ exitCode = 1;
1040
+ }
1041
+ }
1042
+ else if (sub === 'js') {
1043
+ const sub2 = (rest[1] ?? '').toLowerCase();
1044
+ const jsOpts = {
1045
+ port,
1046
+ targetIndex,
1047
+ out: argv['out'],
1048
+ top,
1049
+ durationSec: argv['duration'] !== undefined ? Number(argv['duration']) : undefined,
1050
+ };
1051
+ if (sub2 === 'record') {
1052
+ exitCode = await (0, profile_js_js_1.profileJsRecord)(opts, sessionName, jsOpts);
1053
+ }
1054
+ else if (sub2 === 'start') {
1055
+ exitCode = await (0, profile_js_js_1.profileJsStart)(opts, sessionName, jsOpts);
1056
+ }
1057
+ else if (sub2 === 'stop') {
1058
+ exitCode = await (0, profile_js_js_1.profileJsStop)(opts, sessionName, jsOpts);
1059
+ }
1060
+ else if (sub2 === '_hold') {
1061
+ exitCode = await (0, profile_js_js_1.profileJsHold)(sessionName, jsOpts);
1062
+ }
1063
+ else {
1064
+ console.error('Usage: conductor profile js <record|start|stop>');
1065
+ exitCode = 1;
1066
+ }
1067
+ }
1038
1068
  else if (sub === 'react') {
1039
1069
  const sub2 = (rest[1] ?? '').toLowerCase();
1040
- const top = argv['top'] !== undefined ? Number(argv['top']) : 20;
1070
+ const reactOpts = {
1071
+ port,
1072
+ targetIndex,
1073
+ maxCommits: argv['max-commits'] !== undefined ? Number(argv['max-commits']) : undefined,
1074
+ maxComponents: argv['max-components'] !== undefined ? Number(argv['max-components']) : undefined,
1075
+ timeline: argv['timeline'],
1076
+ };
1041
1077
  if (sub2 === 'start') {
1042
- exitCode = await (0, profile_js_1.profileReactStart)(opts, sessionName, { port, targetIndex });
1078
+ exitCode = await (0, profile_js_1.profileReactStart)(opts, sessionName, reactOpts);
1043
1079
  }
1044
1080
  else if (sub2 === 'stop') {
1045
- exitCode = await (0, profile_js_1.profileReactStop)(opts, sessionName, { port, targetIndex }, top);
1081
+ exitCode = await (0, profile_js_1.profileReactStop)(opts, sessionName, reactOpts, top ?? 20);
1046
1082
  }
1047
1083
  else {
1048
1084
  console.error('Usage: conductor profile react <start|stop>');
@@ -1050,7 +1086,7 @@ async function main() {
1050
1086
  }
1051
1087
  }
1052
1088
  else {
1053
- console.error('Usage: conductor profile <cpu|memory|react> [args]');
1089
+ console.error('Usage: conductor profile <cpu|memory|frames|js|react> [args]');
1054
1090
  exitCode = 1;
1055
1091
  }
1056
1092
  break;
package/dist/stats.js ADDED
@@ -0,0 +1,71 @@
1
+ "use strict";
2
+ /**
3
+ * Percentile / dispersion helpers shared by the profiling commands.
4
+ *
5
+ * Percentiles use nearest-rank on a sorted copy so a p99 over a handful of
6
+ * samples still names a real observation rather than an interpolated one that
7
+ * never happened.
8
+ *
9
+ * With no samples every figure is `null`, never 0. A missing measurement that
10
+ * reads as `0ms` is a missing measurement that reads as a *perfect* one, and a
11
+ * consumer has to remember to check `count` to avoid believing it. `null`
12
+ * propagates through arithmetic and comparison loudly enough to notice.
13
+ */
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.EMPTY_DISTRIBUTION = void 0;
16
+ exports.hasSamples = hasSamples;
17
+ exports.percentile = percentile;
18
+ exports.round = round;
19
+ exports.roundOrNull = roundOrNull;
20
+ exports.describe = describe;
21
+ exports.fmt = fmt;
22
+ exports.EMPTY_DISTRIBUTION = {
23
+ count: 0,
24
+ minMs: null,
25
+ maxMs: null,
26
+ meanMs: null,
27
+ stddevMs: null,
28
+ p50Ms: null,
29
+ p90Ms: null,
30
+ p95Ms: null,
31
+ p99Ms: null,
32
+ };
33
+ function hasSamples(d) {
34
+ return d !== undefined && d.count > 0;
35
+ }
36
+ function percentile(sorted, p) {
37
+ if (sorted.length === 0)
38
+ return null;
39
+ const rank = Math.ceil((p / 100) * sorted.length);
40
+ return sorted[Math.min(sorted.length - 1, Math.max(0, rank - 1))];
41
+ }
42
+ function round(n, digits = 2) {
43
+ const f = 10 ** digits;
44
+ return Math.round(n * f) / f;
45
+ }
46
+ /** `round` that passes `null` through, for optional measurements. */
47
+ function roundOrNull(n, digits = 2) {
48
+ return n === null ? null : round(n, digits);
49
+ }
50
+ function describe(values) {
51
+ if (values.length === 0)
52
+ return { ...exports.EMPTY_DISTRIBUTION };
53
+ const sorted = [...values].sort((a, b) => a - b);
54
+ const mean = sorted.reduce((a, b) => a + b, 0) / sorted.length;
55
+ const variance = sorted.reduce((acc, v) => acc + (v - mean) ** 2, 0) / sorted.length;
56
+ return {
57
+ count: sorted.length,
58
+ minMs: round(sorted[0]),
59
+ maxMs: round(sorted[sorted.length - 1]),
60
+ meanMs: round(mean),
61
+ stddevMs: round(Math.sqrt(variance)),
62
+ p50Ms: roundOrNull(percentile(sorted, 50)),
63
+ p90Ms: roundOrNull(percentile(sorted, 90)),
64
+ p95Ms: roundOrNull(percentile(sorted, 95)),
65
+ p99Ms: roundOrNull(percentile(sorted, 99)),
66
+ };
67
+ }
68
+ /** Format a possibly-absent measurement for text output. */
69
+ function fmt(v, unit = 'ms') {
70
+ return v === null || v === undefined ? 'n/a' : `${v}${unit}`;
71
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@houwert/conductor",
3
- "version": "0.28.0",
3
+ "version": "0.29.1",
4
4
  "description": "CLI tool for mobile app interactions — optimized for AI agents",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -14,7 +14,7 @@ existing Maestro flows run unchanged. Use flows for repeatable journeys; use
14
14
 
15
15
  | Command | Purpose |
16
16
  |---|---|
17
- | `conductor run-flow <file> [--env K=V] [--benchmark]` | Run a Maestro YAML flow file |
17
+ | `conductor run-flow <file> [--env K=V] [--benchmark] [--repeat <n>]` | Run a Maestro YAML flow file. `--benchmark --repeat <n>` reports per-command p50/p90/stddev across runs |
18
18
  | `conductor run-flow-inline '<yaml>' [--benchmark]` | Run inline YAML from the command line |
19
19
  | `conductor run-sequence [--file path.json]` | Run a JSON sequence of conductor commands serially; reads stdin if no `--file` |
20
20
  | `conductor run-parallel --flows-dir <path>` | Shard a directory of flows across all booted devices |
@@ -41,7 +41,7 @@ conductor assert-visible "Dashboard"
41
41
  | `conductor copy-text-from <element>` | Print an element's text (and copy to the iOS clipboard) |
42
42
  | `conductor input-text <text>` | Type into the focused field |
43
43
  | `conductor erase-text [n]` | Erase n characters (default 50) |
44
- | `conductor press-key <key>` | Press a key (Enter, Backspace, Home, …) or a remote button (`Remote Dpad Up/Down/Left/Right/Center`, `Remote Menu`) for tvOS / Android TV / vega. `--long-press` / `--duration <seconds>` holds it |
44
+ | `conductor press-key <key>` | Press a key (Enter, Backspace, Home, …) or a remote button (`Remote Dpad Up/Down/Left/Right/Center`, `Remote Menu`) for tvOS / Android TV / vega. `--long-press` / `--duration <seconds>` holds it; `--measure` times the response (see `conductor-profiler`) |
45
45
  | `conductor hide-keyboard` | Dismiss the on-screen keyboard |
46
46
  | `conductor back` | Press back |
47
47
  | `conductor scroll [--direction down\|up\|left\|right]` | Scroll |
@@ -1,21 +1,116 @@
1
1
  ---
2
2
  name: conductor-profiler
3
- description: Profile a running app's CPU, memory, and React render performance with the conductor CLI, and read crash reports. Use when investigating slowness, jank, memory growth or leaks, excessive React re-renders, or when an app has crashed and you need the crash report.
3
+ description: Profile a running app's frame timing, input latency, JS CPU, native CPU, memory and React renders with the conductor CLI, and read crash reports. Use when investigating jank or sluggishness, slow navigation, input lag on TV remotes, memory growth or leaks, GC pauses, excessive React re-renders, or when an app has crashed and you need the crash report.
4
4
  ---
5
5
 
6
6
  # Conductor — profiling & crashes
7
7
 
8
8
  Measure a running app's performance and inspect crashes.
9
9
 
10
- ## Profiling
10
+ ## Pick the right instrument
11
+
12
+ | Symptom the user reports | Measure with |
13
+ |---|---|
14
+ | "Scrolling/navigation is janky" | `profile frames` (Android) — objective jank rate and per-phase attribution |
15
+ | "I press a key and it takes a moment" | `press-key <key> --measure --repeat 20` |
16
+ | "It's slow, where does the time go?" (JS) | `profile js record --duration 10` |
17
+ | "It's slow, where does the time go?" (native) | `profile cpu --duration 10 --report` |
18
+ | "Which component re-renders on every input?" | `profile react start` → interact → `profile react stop` |
19
+ | "Memory grows / it stutters periodically" | `profile memory --track 30` |
20
+
21
+ `profile frames`, `profile cpu` and `profile memory` work on **release builds**
22
+ and on real hardware. `profile js` and `profile react` attach over Metro, so
23
+ they need a dev/profiling build.
24
+
25
+ ## Frame timing & jank (Android, incl. Fire TV / Android TV)
26
+
27
+ | Command | Purpose |
28
+ |---|---|
29
+ | `conductor profile frames reset [<appId>]` | Zero the gfxinfo counters |
30
+ | `conductor profile frames report [<appId>]` | Jank rate, p50–p99 frame times, per-phase breakdown |
31
+ | `conductor profile frames report --track <s> [--interval <ms>]` | Reset, sample for N seconds, then report |
32
+ | `conductor profile frames report --save-baseline <name>` / `--diff <name>` / `--baselines` | Save and compare runs |
33
+
34
+ Reads `dumpsys gfxinfo <pkg> framestats`, so it needs no instrumentation.
35
+
36
+ **On TV, be deliberate about who is driving.** `press-key` injects with
37
+ `adb shell input keyevent`, which spawns a JVM on the device (~713ms) beside
38
+ the frames you are measuring. Mobile's answer — fling and measure the momentum
39
+ scroll — does not exist on TV, where focus moves one step per press and stops.
40
+ So a navigation capture is always partly a measurement of the harness. Idle
41
+ screens, load settles, cold start and self-running animations are clean; for
42
+ navigation, prefer asking a human to drive the physical remote during the
43
+ window (the command announces the window on stderr for exactly this), and treat
44
+ a divergence between human-driven and automated numbers as the harness.
45
+
46
+ Not available on `vega` — but a **physical Fire TV Stick runs Fire OS
47
+ (Android)** over adb and works fine.
48
+
49
+ Attribute jank by comparing each phase's p95 against its p50: `vsyncDelay` means
50
+ the UI thread was blocked elsewhere, `traversal` means measure/layout,
51
+ `draw` means display-list recording, `issueDraw` means render-thread/GPU.
52
+
53
+ ## Input latency
54
+
55
+ | Command | Purpose |
56
+ |---|---|
57
+ | `conductor press-key <key> --measure` | Time the app's response to the press |
58
+ | `conductor press-key <key> --measure --repeat <n>` | Take n samples and report a distribution |
59
+ | `--sequence <k1,k2>` | Cycle keys so repeats oscillate instead of drifting across the UI |
60
+ | `--timeout <ms>` / `--poll-interval <ms>` / `--settle <ms>` | Give-up time; poll delay; render time to allow |
61
+
62
+ Read `outcome` per sample before the aggregates. `moved` is a measurement;
63
+ `unchanged` means focus queries kept working and the app simply declined to move
64
+ (a rail edge, or a key this screen ignores) and is **not** a hang;
65
+ `query-failed` is the one that suggests something is wedged.
66
+
67
+ `--repeat` is not repeated measurement of one event — pressing Right twenty
68
+ times walks twenty different transitions. Read `byTransition` before the
69
+ aggregate, or use `--sequence` to oscillate between two positions.
70
+
71
+ On Android `pressToFrame` is derived from device-side clocks and is the figure
72
+ to trust. `focusChange` is bounded by how long one hierarchy dump takes, and a
73
+ `round-trip-bound` note fires when that floor dominates. gfxinfo's own
74
+ `inputLatency` appears only when the device populates `NewestInputEvent` — it
75
+ does on a Fire TV Stick, it never does on an NVIDIA SHIELD — so treat it as a
76
+ bonus, never as something whose absence means no input occurred.
77
+
78
+ ## JS CPU (Hermes sampling profiler)
79
+
80
+ | Command | Purpose |
81
+ |---|---|
82
+ | `conductor profile js record --duration <s> [--top n] [--out <path>]` | Sample, then rank functions by self time with `file:line` |
83
+ | `conductor profile js start` / `conductor profile js stop [--top n]` | Same, bracketing a flow you drive in between |
84
+
85
+ Writes a raw `.cpuprofile` you can load in Chrome DevTools (Performance → Load
86
+ profile) or convert with `npx hermes-profile-transformer`.
87
+
88
+ ## Native CPU
11
89
 
12
90
  | Command | Purpose |
13
91
  |---|---|
14
- | `conductor profile cpu --duration <s> [--out <path>]` | Record a CPU trace (iOS: xctrace, Android: simpleperf) |
15
- | `conductor profile memory --track <s> [--interval ms] [<appId>]` | Sample memory for N seconds, report deltas |
16
- | `conductor profile react start` / `profile react stop [--top N]` | Install a React commit-profiler hook, then summarize captured commits |
92
+ | `conductor profile cpu --duration <s> [--out <path>]` | Record a trace (iOS: xctrace, Android: simpleperf) |
93
+ | `conductor profile cpu --duration <s> --report [--top n]` | Android: also return a ranked symbol table |
17
94
 
18
- ## Memory
95
+ Without `--report` on Android you get a binary `perf.data` you cannot read —
96
+ pass `--report` when you need the answer rather than the artefact.
97
+
98
+ ## React renders
99
+
100
+ | Command | Purpose |
101
+ |---|---|
102
+ | `conductor profile react start [--max-commits n] [--max-components n]` | Install the commit-profiler hook |
103
+ | `conductor profile react stop [--top n] [--timeline] [--json]` | Stop and rank components by self time |
104
+
105
+ Sort key is `selfMs`, which is additive across components; `totalMs` is
106
+ subtree-inclusive and double-counts parents. `--json` includes the per-commit
107
+ timeline (timestamps + durations) so you can line a jank spike up with an input;
108
+ `--timeline` adds per-commit component detail. If either buffer overflows the
109
+ output says `truncated: true` with the dropped counts — raise the limits rather
110
+ than trusting the tail. On a release build it fails with a clear message instead
111
+ of reporting zeroes.
112
+
113
+ ## Memory & GC
19
114
 
20
115
  | Command | Purpose |
21
116
  |---|---|
@@ -24,10 +119,17 @@ Measure a running app's performance and inspect crashes.
24
119
  | `conductor memory --leaks` | Run leak detection (iOS only; slow, can pause the app) |
25
120
  | `conductor memory --save <name>` / `--diff <name>` / `--diff <name> --vs <other>` | Snapshot and diff memory reports |
26
121
  | `conductor memory --filter <regex>` / `--growth-only` / `--top <n>` | Narrow object/class tables (great for leak-hunting) |
122
+ | `conductor profile memory --track <s> [--interval <ms>]` | Sample over a window; on Android also reports heap growth and ART GC pause counts/durations |
27
123
 
28
124
  Typical leak hunt: `memory --save before`, exercise the screen, then
29
125
  `memory --diff before --growth-only`.
30
126
 
127
+ ## Repeatable benchmarking
128
+
129
+ `conductor run-flow <file> --benchmark --repeat <n> --json` runs the flow n
130
+ times and reports per-command p50/p90/stddev. Use it on TV, where single-run
131
+ variance is large enough to swamp the effect you're looking for.
132
+
31
133
  ## Crashes
32
134
 
33
135
  | Command | Purpose |
@@ -39,4 +141,10 @@ Typical leak hunt: `memory --save before`, exercise the screen, then
39
141
  ## Tips
40
142
 
41
143
  - Add `--json` to parse reports programmatically.
42
- - These commands can be slow or pause the app scope them with `--duration` / `--track` and avoid leaving `crashes tail` running.
144
+ - `--port` is auto-detected from the device for Metro-backed commands; pass it
145
+ explicitly only if that fails.
146
+ - These commands can be slow or pause the app — scope them with `--duration` /
147
+ `--track` and avoid leaving `crashes tail` running.
148
+ - An Android TV emulator is far faster than a Fire TV Stick. Trust frame timing
149
+ only from real hardware; emulators are fine for counts (React commits, GC
150
+ collections, JS self-time ranking).