@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.
- package/README.md +71 -62
- package/dist/android/device.js +94 -0
- package/dist/commands/debug.js +6 -6
- package/dist/commands/focused.js +27 -0
- package/dist/commands/input-latency.js +348 -0
- package/dist/commands/metro.js +3 -1
- package/dist/commands/native-rn.js +2 -2
- package/dist/commands/network.js +2 -2
- package/dist/commands/press-key.js +106 -60
- package/dist/commands/profile-frames.js +793 -0
- package/dist/commands/profile-gc.js +90 -0
- package/dist/commands/profile-js.js +320 -0
- package/dist/commands/profile.js +391 -77
- package/dist/commands/run-flow.js +70 -4
- package/dist/drivers/flow-runner.js +7 -1
- package/dist/drivers/metro-cdp.js +28 -3
- package/dist/index.js +79 -43
- package/dist/stats.js +71 -0
- package/package.json +1 -1
- package/skills/conductor-create-flow/SKILL.md +1 -1
- package/skills/conductor-device-interact/SKILL.md +1 -1
- package/skills/conductor-profiler/SKILL.md +115 -7
- package/dist/commands/cases.js +0 -260
- package/skills/conductor-test-cases/SKILL.md +0 -65
|
@@ -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
|
+
}
|
package/dist/commands/metro.js
CHANGED
|
@@ -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);
|
package/dist/commands/network.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
194
|
-
|
|
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;
|