@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.
- package/README.md +71 -47
- package/dist/android/device.js +94 -0
- package/dist/commands/debug.js +6 -6
- package/dist/commands/device-pool.js +24 -5
- package/dist/commands/focused.js +27 -0
- package/dist/commands/input-latency.js +348 -0
- package/dist/commands/list-apps.js +8 -1
- package/dist/commands/list-devices.js +30 -1
- 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/bootstrap.js +6 -1
- package/dist/drivers/flow-runner.js +32 -2
- package/dist/drivers/metro-cdp.js +28 -3
- package/dist/index.js +81 -6
- 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-device-setup/SKILL.md +19 -2
- package/skills/conductor-profiler/SKILL.md +115 -7
|
@@ -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
|
+
}
|
|
@@ -23,6 +23,8 @@ async function listApps(opts = {}, sessionName = 'default') {
|
|
|
23
23
|
}
|
|
24
24
|
const platform = await (0, bootstrap_js_1.detectPlatform)(deviceId);
|
|
25
25
|
let appIds;
|
|
26
|
+
// Display names, where the platform gives them up cheaply (iOS/tvOS only).
|
|
27
|
+
const appNames = {};
|
|
26
28
|
if (platform === 'web') {
|
|
27
29
|
(0, output_js_1.printError)('list-apps is not supported on web. Use foreground-app to get the current URL.', opts);
|
|
28
30
|
return 1;
|
|
@@ -43,6 +45,11 @@ async function listApps(opts = {}, sessionName = 'default') {
|
|
|
43
45
|
try {
|
|
44
46
|
const parsed = JSON.parse(result.stdout);
|
|
45
47
|
appIds = Object.keys(parsed).sort();
|
|
48
|
+
for (const id of appIds) {
|
|
49
|
+
const name = parsed[id]?.CFBundleDisplayName ?? parsed[id]?.CFBundleName;
|
|
50
|
+
if (typeof name === 'string' && name)
|
|
51
|
+
appNames[id] = name;
|
|
52
|
+
}
|
|
46
53
|
}
|
|
47
54
|
catch {
|
|
48
55
|
(0, output_js_1.printError)('Failed to parse app list from simctl', opts);
|
|
@@ -62,7 +69,7 @@ async function listApps(opts = {}, sessionName = 'default') {
|
|
|
62
69
|
.sort();
|
|
63
70
|
}
|
|
64
71
|
if (opts.json) {
|
|
65
|
-
(0, output_js_1.printData)({ status: 'ok', apps: appIds }, opts);
|
|
72
|
+
(0, output_js_1.printData)({ status: 'ok', apps: appIds, ...(Object.keys(appNames).length ? { appNames } : {}) }, opts);
|
|
66
73
|
}
|
|
67
74
|
else {
|
|
68
75
|
for (const id of appIds)
|
|
@@ -21,8 +21,25 @@ const cdp_discovery_js_1 = require("../drivers/cdp-discovery.js");
|
|
|
21
21
|
// Module-scoped because the discover function returns Device[]; bolting an
|
|
22
22
|
// extra return field onto the public type would ripple beyond this fix.
|
|
23
23
|
let listAvdsError;
|
|
24
|
+
/**
|
|
25
|
+
* Android TV identifies itself through `ro.build.characteristics` (or the
|
|
26
|
+
* leanback feature). One cheap getprop per device beats guessing from a model
|
|
27
|
+
* name like `sdk_google_atv_x86` or `AFTKA`.
|
|
28
|
+
*/
|
|
29
|
+
async function annotateAndroidFormFactors(devices, ids) {
|
|
30
|
+
await Promise.all(ids.map(async (id) => {
|
|
31
|
+
const device = devices.find((d) => d.id === id);
|
|
32
|
+
if (!device)
|
|
33
|
+
return;
|
|
34
|
+
const res = await (0, runner_js_1.spawnCommand)((0, sdk_js_1.resolveAndroidTool)('adb'), ['-s', id, 'shell', 'getprop', 'ro.build.characteristics'], { env: (0, sdk_js_1.androidSpawnEnv)() });
|
|
35
|
+
if (!res.success)
|
|
36
|
+
return;
|
|
37
|
+
device.formFactor = /\btv\b/i.test(res.stdout) ? 'tv' : 'handset';
|
|
38
|
+
}));
|
|
39
|
+
}
|
|
24
40
|
async function discoverBootedDevices() {
|
|
25
41
|
const devices = [];
|
|
42
|
+
const androidIds = [];
|
|
26
43
|
// Try adb devices (Android)
|
|
27
44
|
const adb = await (0, runner_js_1.spawnCommand)((0, sdk_js_1.resolveAndroidTool)('adb'), ['devices', '-l'], {
|
|
28
45
|
env: (0, sdk_js_1.androidSpawnEnv)(),
|
|
@@ -40,9 +57,11 @@ async function discoverBootedDevices() {
|
|
|
40
57
|
const modelMatch = trimmed.match(/model:(\S+)/);
|
|
41
58
|
const name = modelMatch ? modelMatch[1].replace(/_/g, ' ') : id;
|
|
42
59
|
devices.push({ id, name, platform: 'android', status });
|
|
60
|
+
androidIds.push(id);
|
|
43
61
|
}
|
|
44
62
|
}
|
|
45
63
|
}
|
|
64
|
+
await annotateAndroidFormFactors(devices, androidIds);
|
|
46
65
|
// Try xcrun simctl list (iOS simulators)
|
|
47
66
|
const xcrun = await (0, runner_js_1.spawnCommand)('xcrun', ['simctl', 'list', 'devices', 'booted', '--json']);
|
|
48
67
|
if (xcrun.success) {
|
|
@@ -143,7 +162,17 @@ async function discoverAvailableDevices() {
|
|
|
143
162
|
for (const line of emu.stdout.split('\n')) {
|
|
144
163
|
const name = line.trim();
|
|
145
164
|
if (name) {
|
|
146
|
-
|
|
165
|
+
// A shut-down AVD can't be probed, so fall back to its name: the AVD
|
|
166
|
+
// wizard calls TV images "Television"/"Android TV" or `_atv_`.
|
|
167
|
+
devices.push({
|
|
168
|
+
id: name,
|
|
169
|
+
name,
|
|
170
|
+
platform: 'android',
|
|
171
|
+
status: 'available',
|
|
172
|
+
formFactor: /(^|[_\s-])(tv|television|atv|leanback)([_\s-]|$)/i.test(name)
|
|
173
|
+
? 'tv'
|
|
174
|
+
: 'handset',
|
|
175
|
+
});
|
|
147
176
|
}
|
|
148
177
|
}
|
|
149
178
|
}
|
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);
|