@houwert/conductor 0.27.2 → 0.29.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.
@@ -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) {
@@ -246,6 +246,11 @@ function findPackageRoot() {
246
246
  }
247
247
  const DRIVERS_CACHE_ROOT = path_1.default.join(os_1.default.homedir(), '.conductor', 'drivers');
248
248
  const DRIVERS_DOWNLOAD_BASE = 'https://github.com/DouweBos/conductor/releases/download';
249
+ // Must match the tag `.github/workflows/release.yml` pushes — the two are only
250
+ // ever in sync because a release builds this file and cuts its tag from one
251
+ // commit. Releases up to v0.27.2 predate the prefix and keep their `v<version>`
252
+ // tags, which is fine — each published version only ever fetches its own tag.
253
+ const DRIVERS_TAG_PREFIX = 'cli-v';
249
254
  const DRIVERS_LOCK_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes (download can be slow)
250
255
  const DRIVERS_LOCK_POLL_MS = 500;
251
256
  let _driversDirPromise = null;
@@ -331,7 +336,7 @@ async function ensureDriversCache(pkgRoot) {
331
336
  fs_1.default.rmSync(tmpDir, { recursive: true, force: true });
332
337
  fs_1.default.mkdirSync(tmpDir, { recursive: true });
333
338
  const tarball = path_1.default.join(tmpDir, 'drivers.tar.gz');
334
- const url = `${DRIVERS_DOWNLOAD_BASE}/v${version}/drivers.tar.gz`;
339
+ const url = `${DRIVERS_DOWNLOAD_BASE}/${DRIVERS_TAG_PREFIX}${version}/drivers.tar.gz`;
335
340
  (0, verbose_js_1.log)(`Downloading conductor drivers v${version} from ${url}...`);
336
341
  try {
337
342
  await downloadToFile(url, tarball);
@@ -29,6 +29,23 @@ const utils_js_1 = require("../utils.js");
29
29
  function fmtMs(ms) {
30
30
  return ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(1)}s`;
31
31
  }
32
+ // tvOS remote key names (uppercased, as flow pressKey normalises them) → IOSDriver
33
+ // button values. Without this a flow's `pressKey: "Remote Dpad Up"` would fall
34
+ // through to the software-keyboard path and silently do nothing — tvOS has no
35
+ // keyboard. Mirrors TVOS_REMOTE_BUTTONS in commands/press-key.ts.
36
+ const TVOS_FLOW_BUTTONS = {
37
+ 'REMOTE DPAD UP': 'up',
38
+ 'REMOTE DPAD DOWN': 'down',
39
+ 'REMOTE DPAD LEFT': 'left',
40
+ 'REMOTE DPAD RIGHT': 'right',
41
+ 'REMOTE DPAD CENTER': 'select',
42
+ 'REMOTE MENU': 'menu',
43
+ 'REMOTE MEDIA PLAY PAUSE': 'playPause',
44
+ ENTER: 'select',
45
+ RETURN: 'select',
46
+ ESCAPE: 'menu',
47
+ BACK: 'menu',
48
+ };
32
49
  // vega (Amazon Fire TV) remote key names → VegaDriver button values, for flow pressKey.
33
50
  const VEGA_FLOW_BUTTONS = {
34
51
  BACK: 'back',
@@ -375,6 +392,7 @@ async function executeFlow(flow, driver, opts = {}) {
375
392
  output: opts.output ?? {},
376
393
  depth: opts.depth ?? 0,
377
394
  benchmark: opts.benchmark,
395
+ benchmarkSink: opts.benchmarkSink,
378
396
  };
379
397
  const flowStart = opts.benchmark ? perf_hooks_1.performance.now() : 0;
380
398
  if (flow.onFlowStart?.length) {
@@ -498,9 +516,11 @@ async function executeCommand(cmd, driver, opts) {
498
516
  else {
499
517
  process.stdout.write(`${indent}→ ${label} ... `);
500
518
  }
501
- 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;
502
521
  try {
503
522
  await executeCommandBody(key, resolvedVal, driver, opts);
523
+ opts.benchmarkSink?.({ label, depth: opts.depth, ms: perf_hooks_1.performance.now() - t0, ok: true });
504
524
  const elapsed = opts.benchmark ? ` (${fmtMs(perf_hooks_1.performance.now() - t0)})` : '';
505
525
  if (!isCompound) {
506
526
  console.log(`ok${elapsed}`);
@@ -511,6 +531,7 @@ async function executeCommand(cmd, driver, opts) {
511
531
  }
512
532
  catch (err) {
513
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 });
514
535
  const elapsed = opts.benchmark ? ` (${fmtMs(perf_hooks_1.performance.now() - t0)})` : '';
515
536
  if (optional) {
516
537
  // For compound commands the sub-command already printed its warning/failure
@@ -1055,13 +1076,20 @@ async function executeCommandBody(key, val, driver, opts) {
1055
1076
  case 'pressKey': {
1056
1077
  const keyName = val.toUpperCase();
1057
1078
  if (driver instanceof ios_js_1.IOSDriver) {
1079
+ const tvosButton = driver.platform === 'tvos' ? TVOS_FLOW_BUTTONS[keyName] : undefined;
1058
1080
  // Home and Lock are hardware buttons on iOS, not software keys
1059
- if (keyName === 'HOME') {
1081
+ if (tvosButton) {
1082
+ await driver.pressButton(tvosButton);
1083
+ }
1084
+ else if (keyName === 'HOME') {
1060
1085
  await driver.pressButton('home');
1061
1086
  }
1062
1087
  else if (keyName === 'LOCK' || keyName === 'POWER') {
1063
1088
  await driver.pressButton('lock');
1064
1089
  }
1090
+ else if (driver.platform === 'tvos') {
1091
+ throw new Error(`pressKey: key "${val}" is not supported on tvOS`);
1092
+ }
1065
1093
  else {
1066
1094
  await driver.pressKey(mapIosKey(keyName));
1067
1095
  }
@@ -1249,6 +1277,7 @@ async function executeCommandBody(key, val, driver, opts) {
1249
1277
  output: opts.output,
1250
1278
  depth: childDepth,
1251
1279
  benchmark: opts.benchmark,
1280
+ benchmarkSink: opts.benchmarkSink,
1252
1281
  });
1253
1282
  }
1254
1283
  else {
@@ -1269,6 +1298,7 @@ async function executeCommandBody(key, val, driver, opts) {
1269
1298
  output: opts.output,
1270
1299
  depth: childDepth,
1271
1300
  benchmark: opts.benchmark,
1301
+ benchmarkSink: opts.benchmarkSink,
1272
1302
  });
1273
1303
  }
1274
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");
@@ -223,6 +225,10 @@ async function main() {
223
225
  'force',
224
226
  'yes',
225
227
  'update',
228
+ 'measure',
229
+ 'report',
230
+ 'timeline',
231
+ 'baselines',
226
232
  ],
227
233
  string: [
228
234
  'device',
@@ -254,6 +260,9 @@ async function main() {
254
260
  'source',
255
261
  'level',
256
262
  'save',
263
+ 'save-baseline',
264
+ 'sequence',
265
+ 'settle',
257
266
  'diff',
258
267
  'vs',
259
268
  'top',
@@ -655,6 +664,15 @@ async function main() {
655
664
  exitCode = await (0, press_key_js_1.pressKey)(key, opts, sessionName, {
656
665
  longPress: argv['long-press'],
657
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'],
658
676
  });
659
677
  break;
660
678
  }
@@ -901,7 +919,7 @@ async function main() {
901
919
  const rawEnv = argv['env'];
902
920
  const envPairs = Array.isArray(rawEnv) ? rawEnv : rawEnv ? [rawEnv] : [];
903
921
  const flowEnv = Object.fromEntries(envPairs.map((e) => e.split('=', 2)));
904
- 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);
905
923
  break;
906
924
  }
907
925
  case 'run-flow-inline': {
@@ -942,7 +960,8 @@ async function main() {
942
960
  const release = argv['release'];
943
961
  const releaseId = typeof argv['release'] === 'string' ? argv['release'] : rest[0];
944
962
  const action = acquire ? 'acquire' : release || releaseId ? 'release' : 'list';
945
- exitCode = await (0, device_pool_js_1.devicePool)(action, releaseId, opts);
963
+ const owner = argv['owner'] ? Number(argv['owner']) : undefined;
964
+ exitCode = await (0, device_pool_js_1.devicePool)(action, releaseId, opts, argv['device'], owner);
946
965
  break;
947
966
  }
948
967
  case 'run-sequence': {
@@ -979,12 +998,15 @@ async function main() {
979
998
  const sub = (rest[0] ?? '').toLowerCase();
980
999
  const port = argv['port'] !== undefined ? Number(argv['port']) : undefined;
981
1000
  const targetIndex = argv['target'] !== undefined ? Number(argv['target']) : undefined;
1001
+ const top = argv['top'] !== undefined ? Number(argv['top']) : undefined;
982
1002
  if (sub === 'cpu') {
983
1003
  const durationSec = argv['duration'] !== undefined ? Number(argv['duration']) : 10;
984
1004
  exitCode = await (0, profile_js_1.profileCpu)(opts, sessionName, {
985
1005
  durationSec,
986
1006
  out: argv['out'],
987
1007
  appId: rest[1],
1008
+ report: argv['report'],
1009
+ top,
988
1010
  });
989
1011
  }
990
1012
  else if (sub === 'memory') {
@@ -996,14 +1018,67 @@ async function main() {
996
1018
  appId: rest[1],
997
1019
  });
998
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
+ }
999
1068
  else if (sub === 'react') {
1000
1069
  const sub2 = (rest[1] ?? '').toLowerCase();
1001
- 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
+ };
1002
1077
  if (sub2 === 'start') {
1003
- exitCode = await (0, profile_js_1.profileReactStart)(opts, sessionName, { port, targetIndex });
1078
+ exitCode = await (0, profile_js_1.profileReactStart)(opts, sessionName, reactOpts);
1004
1079
  }
1005
1080
  else if (sub2 === 'stop') {
1006
- 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);
1007
1082
  }
1008
1083
  else {
1009
1084
  console.error('Usage: conductor profile react <start|stop>');
@@ -1011,7 +1086,7 @@ async function main() {
1011
1086
  }
1012
1087
  }
1013
1088
  else {
1014
- console.error('Usage: conductor profile <cpu|memory|react> [args]');
1089
+ console.error('Usage: conductor profile <cpu|memory|frames|js|react> [args]');
1015
1090
  exitCode = 1;
1016
1091
  }
1017
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.27.2",
3
+ "version": "0.29.0",
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 |
@@ -14,7 +14,7 @@ nothing is booted yet.
14
14
  conductor workspace info # detected project type, bundle IDs, devices, Metro port — best first call
15
15
  conductor list-devices # booted + available devices
16
16
  conductor foreground-app # bundle id of the app currently in front
17
- conductor list-apps # installed app ids / package names
17
+ conductor list-apps # installed app ids / package names (--json adds appNames on iOS/tvOS)
18
18
  ```
19
19
 
20
20
  ## Devices
@@ -100,11 +100,28 @@ Parallel agents each get their own `--session <name>` so they don't collide.
100
100
  | `conductor daemon-status` | Show daemon status |
101
101
  | `conductor daemon-stop [--all]` | Stop this session's daemon (`--all` = every session) |
102
102
  | `conductor device-pool --list` | List devices + pool status |
103
- | `conductor device-pool --acquire` | Claim a free device (prints id) |
103
+ | `conductor device-pool --acquire` | Claim a free device (prints id); `--device <id>` claims that one, `--owner <pid>` holds the claim |
104
104
  | `conductor device-pool --release <id>` | Release a device back to the pool |
105
105
 
106
106
  Don't leave a daemon running when you're done — `daemon-stop` it.
107
107
 
108
+ ### Reserving a device
109
+
110
+ Claim a device before driving it when other agents share the machine, so nobody
111
+ taps through your test half-way. A claim belongs to a **process**: conductor
112
+ frees any claim whose owner has exited, and the CLI exits immediately, so
113
+ `--acquire` on its own reserves nothing. Pass the PID that should hold it:
114
+
115
+ ```bash
116
+ conductor device-pool --acquire --device <id> --owner $$ --json # claim it
117
+ conductor device-pool --list --json # who holds what
118
+ conductor device-pool --release <id> # give it back
119
+ ```
120
+
121
+ Acquiring a device someone else holds fails rather than stealing it. Always
122
+ release when you're done — a crash releases it for you, an abandoned shell
123
+ doesn't.
124
+
108
125
  ## Tips
109
126
 
110
127
  - `--device <id>` / `--device-name <name>` targets a device; `--platform` scopes by platform.