@houwert/conductor 0.28.0 → 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.
@@ -0,0 +1,348 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.measureKeyLatency = measureKeyLatency;
4
+ exports.printLatencyReport = printLatencyReport;
5
+ /**
6
+ * Input-to-response latency for `press-key --measure`.
7
+ *
8
+ * Three things had to be got right here, all of them learned by measuring on
9
+ * real Android TV hardware over networked adb rather than assumed:
10
+ *
11
+ * - **A repeat is not a repeated measurement.** Pressing Right twenty times
12
+ * walks focus across twenty *different* transitions, some cheap, some
13
+ * crossing into a lazily-mounted row. Samples are therefore grouped by the
14
+ * transition they actually performed, and the aggregate is reported
15
+ * alongside — never instead of — the per-transition breakdown.
16
+ *
17
+ * - **Focus refusing to move is not a hang.** At the end of a rail the app is
18
+ * correctly declining to move focus. Reporting that as a timeout would be
19
+ * evidence of the sluggishness we are hunting, invented out of correct
20
+ * behaviour. `outcome` distinguishes `moved` / `unchanged` / `query-failed`.
21
+ *
22
+ * - **Over networked adb, host-side polling cannot measure this at all.** One
23
+ * hierarchy dump costs a full round trip (~400ms on a LAN); focus on a
24
+ * healthy TV app moves in 50-150ms. The polling result is then a measurement
25
+ * of the network with a deceptively tight variance, so when `pollCost`
26
+ * dominates we say so in a structured note. The measurement that does work
27
+ * is `pressToFrame`, which is computed entirely from device-side clocks.
28
+ */
29
+ const runner_js_1 = require("../runner.js");
30
+ const bootstrap_js_1 = require("../drivers/bootstrap.js");
31
+ const focused_js_1 = require("./focused.js");
32
+ const press_key_js_1 = require("./press-key.js");
33
+ const stats_js_1 = require("../stats.js");
34
+ const device_js_1 = require("../android/device.js");
35
+ const profile_frames_js_1 = require("./profile-frames.js");
36
+ async function resolveDeviceId(sessionName) {
37
+ if (sessionName !== 'default')
38
+ return sessionName;
39
+ const detected = await (0, runner_js_1.detectFirstDevice)().catch(() => undefined);
40
+ if (!detected)
41
+ throw new Error('no device found; pass --device');
42
+ return detected;
43
+ }
44
+ /**
45
+ * Reset, inject, timestamp, settle and dump — all inside one device-side shell.
46
+ *
47
+ * The timestamp is taken *after* the injection command returns rather than
48
+ * before it. `adb shell input keyevent` costs ~713ms on the device because it
49
+ * spawns an `app_process` JVM per invocation, and that cost lands entirely
50
+ * before the event is dispatched (`input` injects with WAIT_FOR_FINISH and then
51
+ * exits). Reading the clock afterwards therefore puts the press timestamp
52
+ * within a few ms of the actual injection instead of 713ms early, which is the
53
+ * difference between reporting the app's latency and reporting JVM startup.
54
+ *
55
+ * What this does *not* fix is contention: spawning and tearing down a JVM
56
+ * beside the frames being measured is load, and on a 1.7GB device it competes
57
+ * for exactly the resources whose scarcity we are looking for. That residual is
58
+ * reported as a `driver-perturbation` note rather than hidden.
59
+ */
60
+ async function measurePressResponse(deviceId, appId, keycode, settleMs) {
61
+ const bracket = await (0, device_js_1.shellBracketed)(deviceId, `dumpsys gfxinfo ${appId} reset; cat /proc/uptime; input keyevent ${keycode}; ` +
62
+ `cat /proc/uptime; sleep ${(settleMs / 1000).toFixed(2)}; ` +
63
+ `dumpsys gfxinfo ${appId} framestats`);
64
+ if (!bracket)
65
+ return undefined;
66
+ const stamps = [...bracket.stdout.matchAll(/^\s*(\d+\.\d+)\s+\d+\.\d+\s*$/gm)].map((m) => Number(m[1]) * 1000);
67
+ if (stamps.length < 2)
68
+ return undefined;
69
+ const [beforeInject, afterInject] = stamps;
70
+ const frames = (0, profile_frames_js_1.parseFramestats)(bracket.stdout)
71
+ .map((r) => (0, profile_frames_js_1.toFrameSample)(r))
72
+ .filter((f) => f !== null)
73
+ .sort((a, b) => a.vsyncNs - b.vsyncNs);
74
+ // A frame that *started* after the injection is a response to it. Frames
75
+ // drawn during JVM startup are in the buffer too and must not be counted.
76
+ const responding = frames.filter((f) => f.vsyncNs / 1e6 >= afterInject);
77
+ if (responding.length === 0)
78
+ return undefined;
79
+ const first = responding[0];
80
+ const last = responding[responding.length - 1];
81
+ return {
82
+ injectionCostMs: (0, stats_js_1.round)(afterInject - beforeInject),
83
+ response: {
84
+ pressToFrameMs: (0, stats_js_1.round)(first.completedNs / 1e6 - afterInject),
85
+ pressToFrameUpperMs: (0, stats_js_1.round)(first.completedNs / 1e6 - beforeInject),
86
+ dispatchWindowMs: (0, stats_js_1.round)(afterInject - beforeInject),
87
+ renderBurstMs: (0, stats_js_1.round)((last.completedNs - first.vsyncNs) / 1e6),
88
+ framesInBurst: responding.length,
89
+ jankyInBurst: responding.filter((f) => f.totalMs > 16.67).length,
90
+ },
91
+ };
92
+ }
93
+ /** One sample: press, then poll focus until its identity changes or we give up. */
94
+ async function sampleOnce(driver, key, index, opts, pollCosts, androidTarget) {
95
+ let before;
96
+ try {
97
+ before = await (0, focused_js_1.queryFocused)(driver);
98
+ }
99
+ catch {
100
+ return {
101
+ index,
102
+ key,
103
+ outcome: 'query-failed',
104
+ elapsedMs: null,
105
+ dispatchMs: 0,
106
+ polls: 0,
107
+ from: '(unknown)',
108
+ to: '(unknown)',
109
+ };
110
+ }
111
+ const beforeKey = (0, focused_js_1.focusKey)(before);
112
+ let response;
113
+ let injectionCostMs;
114
+ let dispatchMs = 0;
115
+ const t0 = performance.now();
116
+ const keycode = androidTarget ? press_key_js_1.ANDROID_KEYCODE[key] : undefined;
117
+ if (androidTarget && keycode !== undefined) {
118
+ // Injection happens inside the device-side bracket, so the press is timed
119
+ // in the device's own clock domain with no host or adb time in the window.
120
+ const measured = await measurePressResponse(androidTarget.deviceId, androidTarget.appId, keycode, opts.settleMs);
121
+ dispatchMs = (0, stats_js_1.round)(performance.now() - t0);
122
+ response = measured?.response;
123
+ injectionCostMs = measured?.injectionCostMs;
124
+ // The bracket already waited out the settle, so focus has landed.
125
+ }
126
+ else {
127
+ const t = performance.now();
128
+ await (0, press_key_js_1.dispatchKey)(driver, key, opts.holdSeconds);
129
+ dispatchMs = (0, stats_js_1.round)(performance.now() - t);
130
+ }
131
+ const deadline = performance.now() + opts.timeoutMs;
132
+ let polls = 0;
133
+ let queryFailed = false;
134
+ let lastKey = beforeKey;
135
+ while (performance.now() < deadline) {
136
+ const pollStart = performance.now();
137
+ let current;
138
+ try {
139
+ current = await (0, focused_js_1.queryFocused)(driver);
140
+ }
141
+ catch {
142
+ queryFailed = true;
143
+ break;
144
+ }
145
+ const pollEnd = performance.now();
146
+ pollCosts.push((0, stats_js_1.round)(pollEnd - pollStart));
147
+ polls++;
148
+ lastKey = (0, focused_js_1.focusKey)(current);
149
+ if (lastKey !== beforeKey) {
150
+ return {
151
+ index,
152
+ key,
153
+ outcome: 'moved',
154
+ // pollEnd is an upper bound: the change could have landed any time
155
+ // during this dump. pollCost is the error bar.
156
+ elapsedMs: (0, stats_js_1.round)(pollEnd - t0),
157
+ dispatchMs,
158
+ polls,
159
+ from: beforeKey,
160
+ to: lastKey,
161
+ response,
162
+ injectionCostMs,
163
+ };
164
+ }
165
+ if (opts.pollIntervalMs > 0) {
166
+ await new Promise((r) => setTimeout(r, opts.pollIntervalMs));
167
+ }
168
+ }
169
+ return {
170
+ index,
171
+ key,
172
+ // Focus queries kept succeeding and the identity never changed: the app is
173
+ // responding, it simply did not move focus. That is a boundary, not a hang.
174
+ outcome: queryFailed ? 'query-failed' : 'unchanged',
175
+ elapsedMs: null,
176
+ dispatchMs,
177
+ polls,
178
+ from: beforeKey,
179
+ to: lastKey,
180
+ response,
181
+ injectionCostMs,
182
+ };
183
+ }
184
+ function groupByTransition(samples) {
185
+ const groups = new Map();
186
+ for (const s of samples) {
187
+ if (s.outcome !== 'moved')
188
+ continue;
189
+ const id = `${s.key}${s.from}${s.to}`;
190
+ groups.set(id, [...(groups.get(id) ?? []), s]);
191
+ }
192
+ return [...groups.entries()]
193
+ .map(([id, group]) => {
194
+ const [key, from, to] = id.split('');
195
+ return {
196
+ key,
197
+ from,
198
+ to,
199
+ count: group.length,
200
+ focusChange: (0, stats_js_1.describe)(group.map((s) => s.elapsedMs).filter((v) => v !== null)),
201
+ pressToFrame: (0, stats_js_1.describe)(group.map((s) => s.response?.pressToFrameMs).filter((v) => v !== undefined)),
202
+ };
203
+ })
204
+ .sort((a, b) => (b.pressToFrame.p50Ms ?? b.focusChange.p50Ms ?? 0) -
205
+ (a.pressToFrame.p50Ms ?? a.focusChange.p50Ms ?? 0));
206
+ }
207
+ async function measureKeyLatency(sessionName, keys, opts) {
208
+ const deviceId = await resolveDeviceId(sessionName);
209
+ const platform = await (0, bootstrap_js_1.detectPlatform)(deviceId).catch(() => 'unknown');
210
+ const driver = await (0, runner_js_1.getDriver)(sessionName);
211
+ const notes = [];
212
+ let androidTarget;
213
+ let appId;
214
+ if (platform === 'android') {
215
+ appId = opts.appId ?? (await (0, device_js_1.resolveAndroidForegroundApp)(deviceId));
216
+ if (appId) {
217
+ const probe = await (0, device_js_1.adbShell)(deviceId, ['dumpsys', 'gfxinfo', appId]);
218
+ if (probe.success)
219
+ androidTarget = { deviceId, appId };
220
+ }
221
+ }
222
+ const pollCosts = [];
223
+ const samples = [];
224
+ for (let i = 0; i < opts.repeat; i++) {
225
+ samples.push(await sampleOnce(driver, keys[i % keys.length], i, opts, pollCosts, androidTarget));
226
+ }
227
+ const outcomes = { moved: 0, unchanged: 0, 'query-failed': 0 };
228
+ for (const s of samples)
229
+ outcomes[s.outcome]++;
230
+ const moved = samples.filter((s) => s.elapsedMs !== null).map((s) => s.elapsedMs);
231
+ const responses = samples
232
+ .map((s) => s.response?.pressToFrameMs)
233
+ .filter((v) => v !== undefined);
234
+ const focusChange = moved.length > 0 ? (0, stats_js_1.describe)(moved) : undefined;
235
+ const pollCost = pollCosts.length > 0 ? (0, stats_js_1.describe)(pollCosts) : undefined;
236
+ const pressToFrame = responses.length > 0 ? (0, stats_js_1.describe)(responses) : undefined;
237
+ // A poll that costs more than the thing it is timing is measuring itself.
238
+ if ((0, stats_js_1.hasSamples)(focusChange) && (0, stats_js_1.hasSamples)(pollCost)) {
239
+ const ratio = (0, stats_js_1.round)(pollCost.p50Ms / focusChange.p50Ms);
240
+ if (ratio > 0.5) {
241
+ notes.push({
242
+ code: 'round-trip-bound',
243
+ message: `One focus query costs ${pollCost.p50Ms}ms against a focusChange of ` +
244
+ `${focusChange.p50Ms}ms, so this result is bounded by transport, not by the app. ` +
245
+ `Its small variance reflects a stable connection rather than a precise measurement. ` +
246
+ (pressToFrame
247
+ ? 'Use pressToFrame, which is computed from device-side clocks.'
248
+ : 'Connect over USB rather than networked adb, or profile with `profile frames`.'),
249
+ pollCostP50Ms: pollCost.p50Ms,
250
+ focusChangeP50Ms: focusChange.p50Ms,
251
+ ratio,
252
+ });
253
+ }
254
+ }
255
+ // The harness spawns a JVM per keypress. On a memory-constrained TV that is
256
+ // load landing on exactly the resources whose scarcity we are measuring, so
257
+ // it is declared rather than left for the reader to infer.
258
+ const injectionCosts = samples
259
+ .map((s) => s.injectionCostMs)
260
+ .filter((v) => v !== undefined);
261
+ if (injectionCosts.length > 0) {
262
+ const cost = (0, stats_js_1.describe)(injectionCosts);
263
+ if ((0, stats_js_1.hasSamples)(cost) && cost.p50Ms > 100) {
264
+ notes.push({
265
+ code: 'driver-perturbation',
266
+ message: `Each press is injected with \`adb shell input keyevent\`, which spawns an ` +
267
+ `app_process JVM on the device and cost ${cost.p50Ms}ms here. That process starts and ` +
268
+ `tears down alongside the frames being measured, so on a memory-constrained device ` +
269
+ `some of the jank in this window is the harness. pressToFrame excludes the startup ` +
270
+ `time but cannot exclude the contention.`,
271
+ injectionCostMs: cost.p50Ms,
272
+ });
273
+ }
274
+ }
275
+ if (androidTarget && !pressToFrame) {
276
+ notes.push({
277
+ code: 'no-press-to-frame',
278
+ message: 'No frames were attributable to any press, so the device-side measurement is absent. ' +
279
+ 'The app may not be redrawing in response, or --settle is too short.',
280
+ });
281
+ }
282
+ if (outcomes.unchanged > 0) {
283
+ notes.push({
284
+ code: 'boundary-refusals',
285
+ message: `${outcomes.unchanged}/${samples.length} press(es) left focus where it was while focus ` +
286
+ `queries kept succeeding. That is the app declining to move — a rail edge, or a key this ` +
287
+ `screen ignores — not a hang, and it is excluded from the latency figures.`,
288
+ });
289
+ }
290
+ if (outcomes['query-failed'] > 0) {
291
+ notes.push({
292
+ code: 'query-failures',
293
+ message: `${outcomes['query-failed']}/${samples.length} sample(s) could not read the focused ` +
294
+ `element at all. Unlike a boundary refusal this does suggest the app or driver is wedged.`,
295
+ });
296
+ }
297
+ return {
298
+ keys: [...new Set(keys)],
299
+ deviceId,
300
+ platform,
301
+ appId,
302
+ samples,
303
+ outcomes,
304
+ focusChange,
305
+ pressToFrame,
306
+ pollCost,
307
+ byTransition: groupByTransition(samples),
308
+ notes,
309
+ };
310
+ }
311
+ function line(label, d) {
312
+ if (!(0, stats_js_1.hasSamples)(d))
313
+ return ` ${label.padEnd(14)} n/a`;
314
+ return (` ${label.padEnd(14)} p50 ${(0, stats_js_1.fmt)(d.p50Ms)} p90 ${(0, stats_js_1.fmt)(d.p90Ms)} p99 ${(0, stats_js_1.fmt)(d.p99Ms)} ` +
315
+ `max ${(0, stats_js_1.fmt)(d.maxMs)} (n=${d.count}, σ ${(0, stats_js_1.fmt)(d.stddevMs)})`);
316
+ }
317
+ function shortId(key) {
318
+ const [id, , bounds] = key.split('|');
319
+ return id || bounds || key;
320
+ }
321
+ function printLatencyReport(r) {
322
+ console.log(`press-key ${r.keys.join(',')} --measure — ${r.samples.length} sample(s) on ${r.deviceId}`);
323
+ console.log(` outcomes: moved=${r.outcomes.moved} unchanged=${r.outcomes.unchanged} ` +
324
+ `queryFailed=${r.outcomes['query-failed']}`);
325
+ if (r.pressToFrame)
326
+ console.log(line('press→frame', r.pressToFrame));
327
+ console.log(line('focus→move', r.focusChange));
328
+ console.log(line('poll cost', r.pollCost));
329
+ if (r.byTransition.length > 0) {
330
+ console.log('\n by transition (aggregates above mix these together)');
331
+ for (const t of r.byTransition) {
332
+ const primary = (0, stats_js_1.hasSamples)(t.pressToFrame) ? t.pressToFrame : t.focusChange;
333
+ const which = (0, stats_js_1.hasSamples)(t.pressToFrame) ? 'press→frame' : 'focus→move';
334
+ console.log(` ${t.key} ${shortId(t.from)} → ${shortId(t.to)} ` +
335
+ `${which} p50 ${(0, stats_js_1.fmt)(primary.p50Ms)} max ${(0, stats_js_1.fmt)(primary.maxMs)} (n=${t.count})`);
336
+ }
337
+ }
338
+ const withBurst = r.samples.filter((s) => s.response);
339
+ if (withBurst.length > 0) {
340
+ const burst = (0, stats_js_1.describe)(withBurst.map((s) => s.response.renderBurstMs));
341
+ console.log(line('render burst', burst));
342
+ const window = (0, stats_js_1.describe)(withBurst.map((s) => s.response.dispatchWindowMs));
343
+ console.log(` dispatch window p50 ${(0, stats_js_1.fmt)(window.p50Ms)} — press→frame is a lower bound; ` +
344
+ `its upper bound is that much higher.`);
345
+ }
346
+ for (const note of r.notes)
347
+ console.log(`\n note [${note.code}]: ${note.message}`);
348
+ }
@@ -29,6 +29,8 @@ async function pidsOnPort(port) {
29
29
  });
30
30
  }
31
31
  async function metroStop(opts, metroOpts) {
32
+ // Deliberately not auto-discovered: this kills whatever listens on the port,
33
+ // and guessing one risks killing a Metro the user did not mean to stop.
32
34
  const port = metroOpts.port ?? 8081;
33
35
  const pids = await pidsOnPort(port);
34
36
  if (pids.length === 0) {
@@ -65,13 +67,13 @@ async function metroStop(opts, metroOpts) {
65
67
  return 0;
66
68
  }
67
69
  async function metroReload(opts, sessionName, metroOpts) {
68
- const port = metroOpts.port ?? 8081;
69
70
  let deviceId;
70
71
  let platform;
71
72
  if (sessionName && sessionName !== 'default') {
72
73
  deviceId = sessionName;
73
74
  platform = await (0, bootstrap_js_1.detectPlatform)(deviceId).catch(() => undefined);
74
75
  }
76
+ const port = await (0, metro_cdp_js_1.resolveMetroPort)({ port: metroOpts.port, deviceId, platform });
75
77
  // Try CDP Page.reload first (works on Hermes/Fusebox).
76
78
  try {
77
79
  await (0, metro_cdp_js_1.cdpCall)('Page.reload', undefined, {
@@ -55,11 +55,11 @@ async function nativeRnSet(args, opts, sessionName, rnOpts) {
55
55
  }
56
56
  const path = args.path.split('.').filter((s) => s.length > 0);
57
57
  const { json } = parseValue(args.value);
58
- const port = rnOpts.port ?? 8081;
59
58
  const { deviceId, platformPromise } = resolveSession(sessionName);
60
59
  const client = new metro_cdp_js_1.MetroCdpClient();
61
60
  try {
62
61
  const platform = await platformPromise;
62
+ const port = await (0, metro_cdp_js_1.resolveMetroPort)({ port: rnOpts.port, deviceId, platform });
63
63
  await client.connect({ port, deviceId, platform, targetIndex: rnOpts.targetIndex });
64
64
  const raw = await client.evaluate((0, metro_scripts_js_1.makeOverridePropsScript)(tag, path, json), true);
65
65
  const res = JSON.parse(raw);
@@ -86,11 +86,11 @@ async function nativeRnProps(args, opts, sessionName, rnOpts) {
86
86
  (0, output_js_1.printError)('native-rn-props needs --react-tag <n> (from native-inspect rn.reactTag)', opts);
87
87
  return 1;
88
88
  }
89
- const port = rnOpts.port ?? 8081;
90
89
  const { deviceId, platformPromise } = resolveSession(sessionName);
91
90
  const client = new metro_cdp_js_1.MetroCdpClient();
92
91
  try {
93
92
  const platform = await platformPromise;
93
+ const port = await (0, metro_cdp_js_1.resolveMetroPort)({ port: rnOpts.port, deviceId, platform });
94
94
  await client.connect({ port, deviceId, platform, targetIndex: rnOpts.targetIndex });
95
95
  const raw = await client.evaluate((0, metro_scripts_js_1.makeRnPropsScript)(tag), true);
96
96
  const res = JSON.parse(raw);
@@ -140,10 +140,10 @@ async function networkLogs(opts, sessionName, netOpts) {
140
140
  return 1;
141
141
  }
142
142
  }
143
- const port = netOpts.port ?? 8081;
144
143
  const { deviceId, platformPromise } = resolveSession(sessionName);
145
144
  try {
146
145
  const platform = await platformPromise;
146
+ const port = await (0, metro_cdp_js_1.resolveMetroPort)({ port: netOpts.port, deviceId, platform });
147
147
  const client = new metro_cdp_js_1.MetroCdpClient();
148
148
  await client.connect({ port, deviceId, platform, targetIndex: netOpts.targetIndex });
149
149
  await client.evaluate(INSTALL_SHIM_SCRIPT);
@@ -227,10 +227,10 @@ async function networkRequest(url, opts, sessionName, reqOpts) {
227
227
  }
228
228
  })()
229
229
  `;
230
- const port = reqOpts.port ?? 8081;
231
230
  const { deviceId, platformPromise } = resolveSession(sessionName);
232
231
  try {
233
232
  const platform = await platformPromise;
233
+ const port = await (0, metro_cdp_js_1.resolveMetroPort)({ port: reqOpts.port, deviceId, platform });
234
234
  const client = new metro_cdp_js_1.MetroCdpClient();
235
235
  await client.connect({ port, deviceId, platform, targetIndex: reqOpts.targetIndex });
236
236
  const result = await client.evaluate(script);
@@ -1,12 +1,20 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.VALID_KEYS = exports.HELP = void 0;
3
+ exports.ANDROID_KEYCODE = exports.VALID_KEYS = exports.HELP = void 0;
4
+ exports.dispatchKey = dispatchKey;
4
5
  exports.pressKey = pressKey;
5
6
  exports.HELP = ` press-key <key> Press a key (Enter, Backspace, Home, ...)
6
7
  --long-press Hold the button ~1.5s (tvOS remote buttons)
7
- --duration <seconds> Hold for a custom duration (tvOS remote buttons)`;
8
+ --duration <seconds> Hold for a custom duration (tvOS remote buttons)
9
+ --measure Time the app's response to the press
10
+ --repeat <n> With --measure, take n samples and report a distribution
11
+ --sequence <k1,k2,...> With --repeat, cycle these keys so focus oscillates
12
+ --timeout <ms> With --measure, give up on a sample after n ms (default 3000)
13
+ --poll-interval <ms> With --measure, delay between focus polls (default 0)
14
+ --settle <ms> With --measure on Android, render time to allow (default 700)`;
8
15
  const runner_js_1 = require("../runner.js");
9
16
  const output_js_1 = require("../output.js");
17
+ const input_latency_js_1 = require("./input-latency.js");
10
18
  const ios_js_1 = require("../drivers/ios.js");
11
19
  const android_js_1 = require("../drivers/android.js");
12
20
  const web_js_1 = require("../drivers/web.js");
@@ -87,7 +95,7 @@ const VEGA_REMOTE_BUTTONS = {
87
95
  VolumeDown: 'volumeDown',
88
96
  };
89
97
  // Android keyevent codes
90
- const ANDROID_KEYCODE = {
98
+ exports.ANDROID_KEYCODE = {
91
99
  Home: 3,
92
100
  Back: 4,
93
101
  Enter: 66,
@@ -124,6 +132,72 @@ const ANDROID_KEYCODE = {
124
132
  'TV Input HDMI 2': 244,
125
133
  'TV Input HDMI 3': 245,
126
134
  };
135
+ /**
136
+ * Send `key` on an already-connected driver. Split out of `pressKey` so
137
+ * `--measure` can reuse one driver across repeats instead of paying driver
138
+ * setup on every sample.
139
+ */
140
+ async function dispatchKey(driver, matched, holdSeconds) {
141
+ if (driver instanceof ios_js_1.IOSDriver) {
142
+ if (driver.platform === 'tvos') {
143
+ const tvosButton = TVOS_REMOTE_BUTTONS[matched];
144
+ const iosButton = IOS_BUTTON_MAP[matched];
145
+ if (tvosButton) {
146
+ await driver.pressButton(tvosButton, holdSeconds);
147
+ }
148
+ else if (iosButton) {
149
+ await driver.pressButton(iosButton, holdSeconds);
150
+ }
151
+ // Keys not mapped on tvOS are silently ignored
152
+ }
153
+ else {
154
+ const iosKey = IOS_KEY_MAP[matched];
155
+ const iosButton = IOS_BUTTON_MAP[matched];
156
+ if (iosKey) {
157
+ await driver.pressKey(iosKey);
158
+ }
159
+ else if (iosButton) {
160
+ await driver.pressButton(iosButton);
161
+ }
162
+ // Keys not mapped on iOS (e.g. Back, VolumeUp) are silently ignored
163
+ }
164
+ }
165
+ else if (driver instanceof web_js_1.WebDriver) {
166
+ const WEB_KEY_MAP = {
167
+ Enter: 'Enter',
168
+ Tab: 'Tab',
169
+ Backspace: 'Backspace',
170
+ Delete: 'Delete',
171
+ Escape: 'Escape',
172
+ Home: 'Home',
173
+ End: 'End',
174
+ // Canvas webtv apps (Lightning/WPE) navigate focus via the D-pad, which they listen
175
+ // for as arrow keys (and Enter for select). Maps the TV remote onto web keyboard.
176
+ 'Remote Dpad Up': 'ArrowUp',
177
+ 'Remote Dpad Down': 'ArrowDown',
178
+ 'Remote Dpad Left': 'ArrowLeft',
179
+ 'Remote Dpad Right': 'ArrowRight',
180
+ 'Remote Dpad Center': 'Enter',
181
+ };
182
+ const webKey = WEB_KEY_MAP[matched];
183
+ if (webKey) {
184
+ await driver.pressKey(webKey);
185
+ }
186
+ }
187
+ else if (driver instanceof vega_js_1.VegaDriver) {
188
+ const vegaButton = VEGA_REMOTE_BUTTONS[matched];
189
+ if (vegaButton) {
190
+ await driver.pressButton(vegaButton);
191
+ }
192
+ // Keys not mapped on vega are silently ignored
193
+ }
194
+ else if (driver instanceof android_js_1.AndroidDriver) {
195
+ const code = exports.ANDROID_KEYCODE[matched];
196
+ if (code !== undefined) {
197
+ await driver.pressKeyEvent(code);
198
+ }
199
+ }
200
+ }
127
201
  async function pressKey(key, opts = {}, sessionName = 'default', flags = {}) {
128
202
  // A held press is requested via --long-press (default 1.5s) or an explicit --duration.
129
203
  const holdSeconds = flags.duration ?? (flags.longPress ? 1.5 : undefined);
@@ -136,67 +210,39 @@ async function pressKey(key, opts = {}, sessionName = 'default', flags = {}) {
136
210
  (0, output_js_1.printError)(`Unknown key "${key}". Valid keys: ${exports.VALID_KEYS.join(', ')}`, opts);
137
211
  return 1;
138
212
  }
139
- const result = await (0, runner_js_1.runDirect)(async (driver) => {
140
- if (driver instanceof ios_js_1.IOSDriver) {
141
- if (driver.platform === 'tvos') {
142
- const tvosButton = TVOS_REMOTE_BUTTONS[matched];
143
- const iosButton = IOS_BUTTON_MAP[matched];
144
- if (tvosButton) {
145
- await driver.pressButton(tvosButton, holdSeconds);
146
- }
147
- else if (iosButton) {
148
- await driver.pressButton(iosButton, holdSeconds);
149
- }
150
- // Keys not mapped on tvOS are silently ignored
151
- }
152
- else {
153
- const iosKey = IOS_KEY_MAP[matched];
154
- const iosButton = IOS_BUTTON_MAP[matched];
155
- if (iosKey) {
156
- await driver.pressKey(iosKey);
157
- }
158
- else if (iosButton) {
159
- await driver.pressButton(iosButton);
160
- }
161
- // Keys not mapped on iOS (e.g. Back, VolumeUp) are silently ignored
213
+ if (flags.measure) {
214
+ const sequence = [matched];
215
+ for (const raw of flags.sequence ?? []) {
216
+ const k = exports.VALID_KEYS.find((v) => v.toLowerCase() === raw.trim().toLowerCase());
217
+ if (!k) {
218
+ (0, output_js_1.printError)(`Unknown key "${raw}" in --sequence. Valid keys: ${exports.VALID_KEYS.join(', ')}`, opts);
219
+ return 1;
162
220
  }
221
+ sequence.push(k);
163
222
  }
164
- else if (driver instanceof web_js_1.WebDriver) {
165
- const WEB_KEY_MAP = {
166
- Enter: 'Enter',
167
- Tab: 'Tab',
168
- Backspace: 'Backspace',
169
- Delete: 'Delete',
170
- Escape: 'Escape',
171
- Home: 'Home',
172
- End: 'End',
173
- // Canvas webtv apps (Lightning/WPE) navigate focus via the D-pad, which they listen
174
- // for as arrow keys (and Enter for select). Maps the TV remote onto web keyboard.
175
- 'Remote Dpad Up': 'ArrowUp',
176
- 'Remote Dpad Down': 'ArrowDown',
177
- 'Remote Dpad Left': 'ArrowLeft',
178
- 'Remote Dpad Right': 'ArrowRight',
179
- 'Remote Dpad Center': 'Enter',
180
- };
181
- const webKey = WEB_KEY_MAP[matched];
182
- if (webKey) {
183
- await driver.pressKey(webKey);
184
- }
223
+ const measureOpts = {
224
+ repeat: flags.repeat ?? 1,
225
+ timeoutMs: flags.timeoutMs ?? 3000,
226
+ pollIntervalMs: flags.pollIntervalMs ?? 0,
227
+ settleMs: flags.settleMs ?? 700,
228
+ appId: flags.appId,
229
+ holdSeconds,
230
+ };
231
+ try {
232
+ const report = await (0, input_latency_js_1.measureKeyLatency)(sessionName, sequence, measureOpts);
233
+ if (opts.json)
234
+ (0, output_js_1.printData)({ status: 'ok', ...report }, opts);
235
+ else
236
+ (0, input_latency_js_1.printLatencyReport)(report);
237
+ // Nothing measurable at all is a failure; boundary refusals are not.
238
+ return report.outcomes.moved === 0 && !report.pressToFrame ? 1 : 0;
185
239
  }
186
- else if (driver instanceof vega_js_1.VegaDriver) {
187
- const vegaButton = VEGA_REMOTE_BUTTONS[matched];
188
- if (vegaButton) {
189
- await driver.pressButton(vegaButton);
190
- }
191
- // Keys not mapped on vega are silently ignored
240
+ catch (err) {
241
+ (0, output_js_1.printError)(`press-key ${matched} --measure — ${err instanceof Error ? err.message : String(err)}`, opts);
242
+ return 1;
192
243
  }
193
- else if (driver instanceof android_js_1.AndroidDriver) {
194
- const code = ANDROID_KEYCODE[matched];
195
- if (code !== undefined) {
196
- await driver.pressKeyEvent(code);
197
- }
198
- }
199
- }, sessionName);
244
+ }
245
+ const result = await (0, runner_js_1.runDirect)((driver) => dispatchKey(driver, matched, holdSeconds), sessionName);
200
246
  if (result.success) {
201
247
  (0, output_js_1.printSuccess)(`press-key ${matched} — done`, opts);
202
248
  return 0;