@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
package/dist/commands/profile.js
CHANGED
|
@@ -3,25 +3,61 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.HELP = void 0;
|
|
6
|
+
exports.REACT_PROFILER_STOP = exports.REACT_PROFILER_READ = exports.REACT_PROFILER_INSTALL = exports.HELP = void 0;
|
|
7
|
+
exports.parseSimpleperfReport = parseSimpleperfReport;
|
|
7
8
|
exports.profileCpu = profileCpu;
|
|
9
|
+
exports.heapGrowth = heapGrowth;
|
|
8
10
|
exports.profileMemory = profileMemory;
|
|
9
11
|
exports.profileReactStart = profileReactStart;
|
|
10
12
|
exports.profileReactStop = profileReactStop;
|
|
11
13
|
exports.HELP = ` profile cpu --duration <s> [--out <path>]
|
|
12
14
|
Record a CPU trace (iOS: xctrace, Android: simpleperf)
|
|
15
|
+
--report [--top N] Android: also symbolize the trace into a ranked table
|
|
13
16
|
profile memory --track <s> [--interval <ms>] [<appId>]
|
|
14
17
|
Sample memory for N seconds, report deltas
|
|
18
|
+
Android: also reports heap growth and GC pauses
|
|
19
|
+
profile frames reset [<appId>] Android: zero the app's gfxinfo frame counters
|
|
20
|
+
profile frames report [<appId>] Android: jank / frame timing since the last reset
|
|
21
|
+
--track <s> [--interval <ms>] Reset, sample for N seconds, then report
|
|
22
|
+
--save-baseline <name> / --diff <name> / --baselines
|
|
23
|
+
Save, compare against, and list frame baselines
|
|
24
|
+
profile js record --duration <s> Sample Hermes JS CPU and rank functions
|
|
25
|
+
profile js start / stop [--top N] Same, bracketing a flow you drive yourself
|
|
26
|
+
--out <path> Where to write the raw .cpuprofile
|
|
15
27
|
profile react start Install a React commit-profiler hook in the JS runtime
|
|
16
|
-
|
|
28
|
+
--max-commits <n> Commit ring-buffer size (default 500)
|
|
29
|
+
--max-components <n> Component records kept per commit (default 200)
|
|
30
|
+
profile react stop [--top N] Stop and summarise captured React commits
|
|
31
|
+
--timeline Include per-commit component detail in --json`;
|
|
17
32
|
const child_process_1 = require("child_process");
|
|
18
33
|
const os_1 = __importDefault(require("os"));
|
|
19
34
|
const path_1 = __importDefault(require("path"));
|
|
20
35
|
const output_js_1 = require("../output.js");
|
|
21
36
|
const bootstrap_js_1 = require("../drivers/bootstrap.js");
|
|
22
37
|
const sdk_js_1 = require("../android/sdk.js");
|
|
38
|
+
const runner_js_1 = require("../runner.js");
|
|
23
39
|
const memory_js_1 = require("./memory.js");
|
|
24
40
|
const metro_cdp_js_1 = require("../drivers/metro-cdp.js");
|
|
41
|
+
const profile_gc_js_1 = require("./profile-gc.js");
|
|
42
|
+
/**
|
|
43
|
+
* Parse `simpleperf report --sort dso,symbol`. The header row is located by its
|
|
44
|
+
* `Overhead` column rather than by line number, since simpleperf prefixes the
|
|
45
|
+
* table with a variable-length preamble.
|
|
46
|
+
*/
|
|
47
|
+
function parseSimpleperfReport(out) {
|
|
48
|
+
const lines = out.split(/\r?\n/);
|
|
49
|
+
const headerIndex = lines.findIndex((l) => /^\s*Overhead\b/.test(l));
|
|
50
|
+
if (headerIndex === -1)
|
|
51
|
+
return [];
|
|
52
|
+
const entries = [];
|
|
53
|
+
for (const line of lines.slice(headerIndex + 1)) {
|
|
54
|
+
const m = line.match(/^\s*([\d.]+)%\s+(\S+)\s+(.+?)\s*$/);
|
|
55
|
+
if (!m)
|
|
56
|
+
continue;
|
|
57
|
+
entries.push({ percent: Number(m[1]), dso: m[2], symbol: m[3] });
|
|
58
|
+
}
|
|
59
|
+
return entries;
|
|
60
|
+
}
|
|
25
61
|
function defaultTracePath(prefix, ext) {
|
|
26
62
|
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
27
63
|
return path_1.default.join(os_1.default.tmpdir(), `${prefix}-${ts}.${ext}`);
|
|
@@ -47,7 +83,7 @@ async function recordIosCpu(deviceId, appId, durationSec, out) {
|
|
|
47
83
|
proc.on('error', reject);
|
|
48
84
|
});
|
|
49
85
|
}
|
|
50
|
-
async function recordAndroidCpu(deviceId, appId, durationSec, out) {
|
|
86
|
+
async function recordAndroidCpu(deviceId, appId, durationSec, out, report) {
|
|
51
87
|
const adb = (0, sdk_js_1.resolveAndroidTool)('adb');
|
|
52
88
|
const env = (0, sdk_js_1.androidSpawnEnv)();
|
|
53
89
|
const remote = `/data/local/tmp/conductor-perf-${Date.now()}.data`;
|
|
@@ -73,6 +109,31 @@ async function recordAndroidCpu(deviceId, appId, durationSec, out) {
|
|
|
73
109
|
proc.on('close', (code) => code === 0 ? resolve() : reject(new Error(`simpleperf record exited with ${code}`)));
|
|
74
110
|
proc.on('error', reject);
|
|
75
111
|
});
|
|
112
|
+
// Symbolize on-device before pulling: /system/bin/simpleperf resolves against
|
|
113
|
+
// the libraries actually loaded there, which a host-side report cannot do
|
|
114
|
+
// without a matching symfs.
|
|
115
|
+
let entries;
|
|
116
|
+
if (report) {
|
|
117
|
+
const res = await (0, runner_js_1.spawnCommand)(adb, [
|
|
118
|
+
'-s',
|
|
119
|
+
deviceId,
|
|
120
|
+
'shell',
|
|
121
|
+
'simpleperf',
|
|
122
|
+
'report',
|
|
123
|
+
'-i',
|
|
124
|
+
remote,
|
|
125
|
+
'--sort',
|
|
126
|
+
'dso,symbol',
|
|
127
|
+
'--percent-limit',
|
|
128
|
+
'0.1',
|
|
129
|
+
], { env });
|
|
130
|
+
if (!res.success) {
|
|
131
|
+
throw new Error(`simpleperf report failed on-device (${res.stderr.trim() || 'no output'}). ` +
|
|
132
|
+
`The raw recording is still at ${out}; symbolize it on the host with the NDK's ` +
|
|
133
|
+
`simpleperf/report.py.`);
|
|
134
|
+
}
|
|
135
|
+
entries = parseSimpleperfReport(res.stdout).slice(0, report.top);
|
|
136
|
+
}
|
|
76
137
|
await new Promise((resolve, reject) => {
|
|
77
138
|
const proc = (0, child_process_1.spawn)(adb, ['-s', deviceId, 'pull', remote, out], { stdio: 'inherit', env });
|
|
78
139
|
proc.on('close', (code) => code === 0 ? resolve() : reject(new Error(`adb pull exited with ${code}`)));
|
|
@@ -83,6 +144,7 @@ async function recordAndroidCpu(deviceId, appId, durationSec, out) {
|
|
|
83
144
|
proc.on('close', () => resolve());
|
|
84
145
|
proc.on('error', () => resolve());
|
|
85
146
|
});
|
|
147
|
+
return entries;
|
|
86
148
|
}
|
|
87
149
|
async function profileCpu(opts, sessionName, profileOpts) {
|
|
88
150
|
if (sessionName === 'default') {
|
|
@@ -92,26 +154,45 @@ async function profileCpu(opts, sessionName, profileOpts) {
|
|
|
92
154
|
const platform = await (0, bootstrap_js_1.detectPlatform)(sessionName).catch(() => null);
|
|
93
155
|
const isIos = platform === 'ios' || platform === 'tvos';
|
|
94
156
|
const out = profileOpts.out ?? defaultTracePath('cpu', isIos ? 'trace' : 'perf.data');
|
|
157
|
+
let entries;
|
|
95
158
|
try {
|
|
96
159
|
if (isIos) {
|
|
160
|
+
if (profileOpts.report) {
|
|
161
|
+
(0, output_js_1.printError)('profile cpu --report is Android-only. Export the iOS trace with ' +
|
|
162
|
+
'`xcrun xctrace export --input <trace> --toc` to see what it holds.', opts);
|
|
163
|
+
return 1;
|
|
164
|
+
}
|
|
97
165
|
await recordIosCpu(sessionName, profileOpts.appId, profileOpts.durationSec, out);
|
|
98
166
|
}
|
|
99
167
|
else if (platform === 'android') {
|
|
100
|
-
await recordAndroidCpu(sessionName, profileOpts.appId, profileOpts.durationSec, out);
|
|
168
|
+
entries = await recordAndroidCpu(sessionName, profileOpts.appId, profileOpts.durationSec, out, profileOpts.report ? { top: profileOpts.top ?? 30 } : undefined);
|
|
101
169
|
}
|
|
102
170
|
else if (platform === 'vega') {
|
|
103
|
-
//
|
|
104
|
-
|
|
171
|
+
// Vega is Amazon's own OS, not Android — no simpleperf, no dumpsys. A
|
|
172
|
+
// physical Fire TV Stick runs Fire OS (Android) over adb and is a
|
|
173
|
+
// different target entirely, where all the Android tooling applies.
|
|
174
|
+
(0, output_js_1.printError)('profile cpu is not supported on vega (Amazon Vega OS — no simpleperf on device).\n' +
|
|
175
|
+
'A physical Fire TV Stick runs Fire OS, which is Android: connect it with ' +
|
|
176
|
+
'`adb connect <ip>` and conductor treats it as an android device, where ' +
|
|
177
|
+
'`profile cpu`, `profile frames` and `profile memory` all work.', opts);
|
|
105
178
|
return 1;
|
|
106
179
|
}
|
|
107
180
|
else {
|
|
108
181
|
(0, output_js_1.printError)(`profile cpu is not supported on platform ${platform ?? '(unknown)'}`, opts);
|
|
109
182
|
return 1;
|
|
110
183
|
}
|
|
111
|
-
if (opts.json)
|
|
112
|
-
(0, output_js_1.printData)({ out, durationSec: profileOpts.durationSec, platform }, opts);
|
|
113
|
-
|
|
184
|
+
if (opts.json) {
|
|
185
|
+
(0, output_js_1.printData)({ out, durationSec: profileOpts.durationSec, platform, symbols: entries }, opts);
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
114
188
|
(0, output_js_1.printSuccess)(`profile cpu — recorded ${profileOpts.durationSec}s → ${out}`, opts);
|
|
189
|
+
if (entries) {
|
|
190
|
+
console.log(` ${'overhead'.padStart(9)} symbol`);
|
|
191
|
+
for (const e of entries) {
|
|
192
|
+
console.log(` ${`${e.percent}%`.padStart(9)} ${e.symbol} [${e.dso}]`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
115
196
|
return 0;
|
|
116
197
|
}
|
|
117
198
|
catch (err) {
|
|
@@ -119,8 +200,41 @@ async function profileCpu(opts, sessionName, profileOpts) {
|
|
|
119
200
|
return 1;
|
|
120
201
|
}
|
|
121
202
|
}
|
|
203
|
+
/** Growth of each numeric field of `report.app` across the tracked window. */
|
|
204
|
+
function heapGrowth(samples) {
|
|
205
|
+
const series = new Map();
|
|
206
|
+
for (const s of samples) {
|
|
207
|
+
const app = s.data?.app;
|
|
208
|
+
if (!app)
|
|
209
|
+
continue;
|
|
210
|
+
for (const [k, v] of Object.entries(app)) {
|
|
211
|
+
if (typeof v !== 'number')
|
|
212
|
+
continue;
|
|
213
|
+
const arr = series.get(k) ?? [];
|
|
214
|
+
arr.push(v);
|
|
215
|
+
series.set(k, arr);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return [...series.entries()]
|
|
219
|
+
.map(([key, values]) => ({
|
|
220
|
+
key,
|
|
221
|
+
startBytes: values[0],
|
|
222
|
+
endBytes: values[values.length - 1],
|
|
223
|
+
deltaBytes: values[values.length - 1] - values[0],
|
|
224
|
+
peakBytes: Math.max(...values),
|
|
225
|
+
}))
|
|
226
|
+
.sort((a, b) => b.deltaBytes - a.deltaBytes);
|
|
227
|
+
}
|
|
228
|
+
function mb(bytes) {
|
|
229
|
+
return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
|
|
230
|
+
}
|
|
122
231
|
async function profileMemory(opts, sessionName, profileOpts) {
|
|
123
232
|
const samples = [];
|
|
233
|
+
const platform = await (0, bootstrap_js_1.detectPlatform)(sessionName).catch(() => undefined);
|
|
234
|
+
const isAndroid = platform === 'android';
|
|
235
|
+
// Note the device's own clock before sampling so the logcat window lines up
|
|
236
|
+
// with it even when the host clock has drifted.
|
|
237
|
+
const gcSince = isAndroid ? await (0, profile_gc_js_1.deviceLogcatTimestamp)(sessionName) : undefined;
|
|
124
238
|
const start = Date.now();
|
|
125
239
|
const end = start + profileOpts.trackSec * 1000;
|
|
126
240
|
while (Date.now() < end) {
|
|
@@ -142,8 +256,15 @@ async function profileMemory(opts, sessionName, profileOpts) {
|
|
|
142
256
|
return { at: s.at, data: null };
|
|
143
257
|
}
|
|
144
258
|
});
|
|
259
|
+
const growth = heapGrowth(parsed);
|
|
260
|
+
let gc;
|
|
261
|
+
if (isAndroid) {
|
|
262
|
+
const events = await (0, profile_gc_js_1.collectGcSince)(sessionName, gcSince);
|
|
263
|
+
if (events.length > 0)
|
|
264
|
+
gc = (0, profile_gc_js_1.summariseGc)(events);
|
|
265
|
+
}
|
|
145
266
|
if (opts.json) {
|
|
146
|
-
(0, output_js_1.printData)({ samples: parsed, durationMs: Date.now() - start }, opts);
|
|
267
|
+
(0, output_js_1.printData)({ samples: parsed, durationMs: Date.now() - start, growth, gc }, opts);
|
|
147
268
|
}
|
|
148
269
|
else {
|
|
149
270
|
console.log(`profile memory — ${samples.length} samples over ${profileOpts.trackSec}s`);
|
|
@@ -156,6 +277,25 @@ async function profileMemory(opts, sessionName, profileOpts) {
|
|
|
156
277
|
: '(parse error)';
|
|
157
278
|
console.log(` t+${(p.at / 1000).toFixed(1)}s ${summary}`);
|
|
158
279
|
}
|
|
280
|
+
if (growth.length > 0) {
|
|
281
|
+
console.log('\n heap growth over the window');
|
|
282
|
+
for (const g of growth.slice(0, 8)) {
|
|
283
|
+
const sign = g.deltaBytes >= 0 ? '+' : '';
|
|
284
|
+
console.log(` ${g.key.padEnd(20)} ${mb(g.startBytes)} → ${mb(g.endBytes)} ` +
|
|
285
|
+
`${sign}${mb(g.deltaBytes)} peak ${mb(g.peakBytes)}`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
if (gc) {
|
|
289
|
+
console.log(`\n GC: ${gc.events} collection(s), ${gc.totalPauseMs}ms of stop-the-world pause total`);
|
|
290
|
+
console.log(` pause p50 ${gc.pause.p50Ms}ms p95 ${gc.pause.p95Ms}ms max ${gc.pause.maxMs}ms`);
|
|
291
|
+
for (const k of gc.byKind) {
|
|
292
|
+
console.log(` ${k.kind.padEnd(34)} ${String(k.count).padStart(4)}x ${k.totalPauseMs}ms`);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
else if (isAndroid) {
|
|
296
|
+
console.log('\n GC: no ART collections logged in the window. ART only logs collections it ' +
|
|
297
|
+
'considers noteworthy, so a quiet window here is a real signal.');
|
|
298
|
+
}
|
|
159
299
|
}
|
|
160
300
|
return 0;
|
|
161
301
|
}
|
|
@@ -175,67 +315,190 @@ async function captureStdout(fn) {
|
|
|
175
315
|
return chunks.join('');
|
|
176
316
|
}
|
|
177
317
|
// ── React profiler ────────────────────────────────────────────────────────────
|
|
178
|
-
|
|
318
|
+
/**
|
|
319
|
+
* Injected commit profiler.
|
|
320
|
+
*
|
|
321
|
+
* The subtle part is deciding which fibers actually rendered in a given commit.
|
|
322
|
+
* React does not clear `actualDuration` on fibers it left alone, so "has a
|
|
323
|
+
* non-zero actualDuration" is true for anything that rendered at any point in
|
|
324
|
+
* the past, and counting those every commit inflates both render counts and
|
|
325
|
+
* durations. What React does clear is `actualStartTime`: `createWorkInProgress`
|
|
326
|
+
* resets it to -1 and `startProfilerTimer` stamps it as work begins, and a
|
|
327
|
+
* render pass always begins at the root. So the root's own `actualStartTime` is
|
|
328
|
+
* the start of this pass, and a fiber rendered in it exactly when its start time
|
|
329
|
+
* is at or after the root's. Anchoring on the root rather than on a running
|
|
330
|
+
* maximum keeps this independent of whichever clock React's `now()` is bound to.
|
|
331
|
+
*
|
|
332
|
+
* That render passes always walk down from the root also lets us prune: a fiber
|
|
333
|
+
* that did not render cannot contain one that did, so the walk costs the size of
|
|
334
|
+
* the rendered set, not the size of the tree.
|
|
335
|
+
*
|
|
336
|
+
* `actualDuration` bubbles up, so a fiber's own cost is its duration minus that
|
|
337
|
+
* of the children that rendered alongside it. Both are reported: `selfMs` is
|
|
338
|
+
* additive across components, `totalMs` is subtree-inclusive and double-counts
|
|
339
|
+
* by design.
|
|
340
|
+
*/
|
|
341
|
+
const REACT_PROFILER_INSTALL = (maxCommits, maxComponents) => `
|
|
179
342
|
(() => {
|
|
180
343
|
if (globalThis.__CONDUCTOR_REACT_PROFILER__) {
|
|
181
344
|
return { installed: true, already: true };
|
|
182
345
|
}
|
|
183
346
|
const hook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
|
184
|
-
if (!hook) return { installed: false, error: 'No React DevTools hook
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
347
|
+
if (!hook) return { installed: false, error: 'No React DevTools hook on this runtime' };
|
|
348
|
+
|
|
349
|
+
var state = {
|
|
350
|
+
commits: [],
|
|
351
|
+
maxCommits: ${maxCommits},
|
|
352
|
+
maxComponents: ${maxComponents},
|
|
353
|
+
droppedCommits: 0,
|
|
354
|
+
droppedComponents: 0,
|
|
355
|
+
skippedCommits: 0,
|
|
356
|
+
profilingSupported: null
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
// Detect up front whether this build carries React's timing instrumentation,
|
|
360
|
+
// so profile react start can fail loudly rather than leaving it to stop.
|
|
361
|
+
var rendererCount = 0;
|
|
362
|
+
try {
|
|
363
|
+
if (hook.renderers && hook.getFiberRoots) {
|
|
364
|
+
hook.renderers.forEach(function (_r, id) {
|
|
365
|
+
rendererCount++;
|
|
366
|
+
var roots = hook.getFiberRoots(id);
|
|
367
|
+
if (!roots) return;
|
|
368
|
+
roots.forEach(function (root) {
|
|
369
|
+
if (state.profilingSupported === null) {
|
|
370
|
+
state.profilingSupported = root.current.actualDuration !== undefined;
|
|
371
|
+
}
|
|
372
|
+
});
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
} catch (e) {}
|
|
376
|
+
|
|
377
|
+
var orig = hook.onCommitFiberRoot;
|
|
378
|
+
hook.onCommitFiberRoot = function (rendererID, root, priorityLevel) {
|
|
189
379
|
try {
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
380
|
+
var rootFiber = root.current;
|
|
381
|
+
if (state.profilingSupported === null) {
|
|
382
|
+
state.profilingSupported = rootFiber.actualDuration !== undefined;
|
|
383
|
+
}
|
|
384
|
+
var passStart = rootFiber.actualStartTime;
|
|
385
|
+
if (state.profilingSupported && !(typeof passStart === 'number' && passStart >= 0)) {
|
|
386
|
+
// No start stamp on the root means we cannot tell this commit's work
|
|
387
|
+
// from earlier work; skipping is better than reporting stale fibers.
|
|
388
|
+
state.skippedCommits++;
|
|
389
|
+
} else if (state.profilingSupported) {
|
|
390
|
+
var components = [];
|
|
391
|
+
var droppedHere = 0;
|
|
392
|
+
|
|
393
|
+
var visit = function (fiber, depth) {
|
|
394
|
+
var start = fiber.actualStartTime;
|
|
395
|
+
if (!(typeof start === 'number' && start >= passStart)) return 0;
|
|
396
|
+
|
|
397
|
+
var childSum = 0;
|
|
398
|
+
var child = fiber.child;
|
|
399
|
+
while (child) {
|
|
400
|
+
childSum += visit(child, depth + 1);
|
|
401
|
+
child = child.sibling;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
var dur = fiber.actualDuration || 0;
|
|
405
|
+
var name =
|
|
406
|
+
(fiber.type && (fiber.type.displayName || fiber.type.name)) ||
|
|
407
|
+
(typeof fiber.type === 'string' ? fiber.type : null);
|
|
200
408
|
if (name) {
|
|
201
|
-
|
|
202
|
-
|
|
409
|
+
if (components.length < state.maxComponents) {
|
|
410
|
+
components.push({
|
|
411
|
+
name: name,
|
|
412
|
+
depth: depth,
|
|
413
|
+
actualDuration: dur,
|
|
414
|
+
selfDuration: Math.max(0, dur - childSum)
|
|
415
|
+
});
|
|
416
|
+
} else {
|
|
417
|
+
droppedHere++;
|
|
418
|
+
}
|
|
203
419
|
}
|
|
420
|
+
return dur;
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
visit(rootFiber, 0);
|
|
424
|
+
state.droppedComponents += droppedHere;
|
|
425
|
+
state.commits.push({
|
|
426
|
+
at: Date.now(),
|
|
427
|
+
rendererID: rendererID,
|
|
428
|
+
durationMs: rootFiber.actualDuration || 0,
|
|
429
|
+
truncated: droppedHere > 0,
|
|
430
|
+
droppedComponents: droppedHere,
|
|
431
|
+
components: components
|
|
432
|
+
});
|
|
433
|
+
while (state.commits.length > state.maxCommits) {
|
|
434
|
+
state.commits.shift();
|
|
435
|
+
state.droppedCommits++;
|
|
204
436
|
}
|
|
205
|
-
if (fiber.child) stack.push({ fiber: fiber.child, depth: depth + 1 });
|
|
206
|
-
if (fiber.sibling) stack.push({ fiber: fiber.sibling, depth });
|
|
207
437
|
}
|
|
208
|
-
commits.push(entry);
|
|
209
|
-
if (commits.length > MAX) commits.shift();
|
|
210
438
|
} catch (e) {}
|
|
211
439
|
if (typeof orig === 'function') return orig.apply(this, arguments);
|
|
212
440
|
};
|
|
441
|
+
|
|
213
442
|
globalThis.__CONDUCTOR_REACT_PROFILER__ = {
|
|
214
443
|
installed: true,
|
|
215
|
-
|
|
216
|
-
uninstall: ()
|
|
444
|
+
state: state,
|
|
445
|
+
uninstall: function () { hook.onCommitFiberRoot = orig; }
|
|
446
|
+
};
|
|
447
|
+
return {
|
|
448
|
+
installed: true,
|
|
449
|
+
already: false,
|
|
450
|
+
renderers: rendererCount,
|
|
451
|
+
profilingSupported: state.profilingSupported
|
|
217
452
|
};
|
|
218
|
-
return { installed: true, already: false };
|
|
219
453
|
})()
|
|
220
454
|
`;
|
|
221
|
-
|
|
455
|
+
exports.REACT_PROFILER_INSTALL = REACT_PROFILER_INSTALL;
|
|
456
|
+
const REACT_PROFILER_READ = (top, timeline) => `
|
|
222
457
|
(() => {
|
|
223
458
|
const p = globalThis.__CONDUCTOR_REACT_PROFILER__;
|
|
224
|
-
if (!p) return { installed: false
|
|
225
|
-
const
|
|
459
|
+
if (!p) return { installed: false };
|
|
460
|
+
const s = p.state;
|
|
226
461
|
const byName = {};
|
|
227
|
-
for (const c of commits) {
|
|
462
|
+
for (const c of s.commits) {
|
|
228
463
|
for (const comp of c.components) {
|
|
229
|
-
byName[comp.name]
|
|
230
|
-
|
|
231
|
-
|
|
464
|
+
const e = byName[comp.name] || (byName[comp.name] = {
|
|
465
|
+
name: comp.name, selfMs: 0, totalMs: 0, renders: 0
|
|
466
|
+
});
|
|
467
|
+
e.selfMs += comp.selfDuration;
|
|
468
|
+
e.totalMs += comp.actualDuration;
|
|
469
|
+
e.renders += 1;
|
|
232
470
|
}
|
|
233
471
|
}
|
|
234
|
-
const
|
|
235
|
-
|
|
472
|
+
const ranked = Object.keys(byName).map(function (k) { return byName[k]; })
|
|
473
|
+
.sort(function (a, b) { return b.selfMs - a.selfMs; });
|
|
474
|
+
const commits = s.commits.map(function (c) {
|
|
475
|
+
const out = {
|
|
476
|
+
at: c.at,
|
|
477
|
+
durationMs: c.durationMs,
|
|
478
|
+
componentCount: c.components.length,
|
|
479
|
+
droppedComponents: c.droppedComponents
|
|
480
|
+
};
|
|
481
|
+
if (${timeline ? 'true' : 'false'}) out.components = c.components;
|
|
482
|
+
return out;
|
|
483
|
+
});
|
|
484
|
+
return {
|
|
485
|
+
installed: true,
|
|
486
|
+
profilingSupported: s.profilingSupported,
|
|
487
|
+
totalCommits: s.commits.length,
|
|
488
|
+
droppedCommits: s.droppedCommits,
|
|
489
|
+
skippedCommits: s.skippedCommits,
|
|
490
|
+
droppedComponents: s.droppedComponents,
|
|
491
|
+
truncated: s.droppedCommits > 0 || s.droppedComponents > 0,
|
|
492
|
+
maxCommits: s.maxCommits,
|
|
493
|
+
maxComponents: s.maxComponents,
|
|
494
|
+
componentsRanked: ranked.length,
|
|
495
|
+
top: ranked.slice(0, ${top}),
|
|
496
|
+
commits: commits
|
|
497
|
+
};
|
|
236
498
|
})()
|
|
237
499
|
`;
|
|
238
|
-
|
|
500
|
+
exports.REACT_PROFILER_READ = REACT_PROFILER_READ;
|
|
501
|
+
exports.REACT_PROFILER_STOP = `
|
|
239
502
|
(() => {
|
|
240
503
|
const p = globalThis.__CONDUCTOR_REACT_PROFILER__;
|
|
241
504
|
if (!p) return { installed: false };
|
|
@@ -244,62 +507,113 @@ const REACT_PROFILER_STOP = `
|
|
|
244
507
|
return { installed: true, stopped: true };
|
|
245
508
|
})()
|
|
246
509
|
`;
|
|
247
|
-
|
|
510
|
+
const NO_PROFILING_BUILD = 'this build has no React profiling data (fibers carry no actualDuration). ' +
|
|
511
|
+
'The React commit profiler needs a dev or profiling build; a release build ' +
|
|
512
|
+
'strips the timing instrumentation. Use `profile js` or `profile frames` to ' +
|
|
513
|
+
'measure a release build.';
|
|
514
|
+
async function connectCdp(sessionName, cdpOpts) {
|
|
515
|
+
const platform = await (0, bootstrap_js_1.detectPlatform)(sessionName).catch(() => undefined);
|
|
516
|
+
const client = new metro_cdp_js_1.MetroCdpClient();
|
|
517
|
+
await client.connect({
|
|
518
|
+
port: cdpOpts.port,
|
|
519
|
+
deviceId: sessionName !== 'default' ? sessionName : undefined,
|
|
520
|
+
platform,
|
|
521
|
+
targetIndex: cdpOpts.targetIndex,
|
|
522
|
+
});
|
|
523
|
+
return client;
|
|
524
|
+
}
|
|
525
|
+
async function profileReactStart(opts, sessionName, reactOpts) {
|
|
526
|
+
let client;
|
|
248
527
|
try {
|
|
249
|
-
|
|
250
|
-
const
|
|
251
|
-
await client.connect({
|
|
252
|
-
port: cdpOpts.port ?? 8081,
|
|
253
|
-
deviceId: sessionName !== 'default' ? sessionName : undefined,
|
|
254
|
-
platform,
|
|
255
|
-
targetIndex: cdpOpts.targetIndex,
|
|
256
|
-
});
|
|
257
|
-
const result = await client.evaluate(REACT_PROFILER_INSTALL);
|
|
258
|
-
client.close();
|
|
528
|
+
client = await connectCdp(sessionName, reactOpts);
|
|
529
|
+
const result = await client.evaluate((0, exports.REACT_PROFILER_INSTALL)(reactOpts.maxCommits ?? 500, reactOpts.maxComponents ?? 200));
|
|
259
530
|
if (!result.installed) {
|
|
260
531
|
(0, output_js_1.printError)(`profile react start — ${result.error ?? 'install failed'}`, opts);
|
|
261
532
|
return 1;
|
|
262
533
|
}
|
|
534
|
+
if (result.profilingSupported === false) {
|
|
535
|
+
await client.evaluate(exports.REACT_PROFILER_STOP);
|
|
536
|
+
(0, output_js_1.printError)(`profile react start — ${NO_PROFILING_BUILD}`, opts);
|
|
537
|
+
return 1;
|
|
538
|
+
}
|
|
263
539
|
if (opts.json)
|
|
264
|
-
(0, output_js_1.printData)(result, opts);
|
|
265
|
-
else
|
|
540
|
+
(0, output_js_1.printData)({ status: 'ok', ...result }, opts);
|
|
541
|
+
else {
|
|
266
542
|
(0, output_js_1.printSuccess)(`profile react start — ${result.already ? 'already installed' : 'installed'}`, opts);
|
|
543
|
+
if (result.profilingSupported === null) {
|
|
544
|
+
console.log(' note: nothing has rendered yet, so the build could not be checked for profiling ' +
|
|
545
|
+
'support. `profile react stop` will say so if it turns out to be a release build.');
|
|
546
|
+
}
|
|
547
|
+
}
|
|
267
548
|
return 0;
|
|
268
549
|
}
|
|
269
550
|
catch (err) {
|
|
270
551
|
(0, output_js_1.printError)(`profile react start — ${err instanceof Error ? err.message : String(err)}`, opts);
|
|
271
552
|
return 1;
|
|
272
553
|
}
|
|
554
|
+
finally {
|
|
555
|
+
client?.close();
|
|
556
|
+
}
|
|
273
557
|
}
|
|
274
|
-
async function profileReactStop(opts, sessionName,
|
|
558
|
+
async function profileReactStop(opts, sessionName, reactOpts, top) {
|
|
559
|
+
let client;
|
|
275
560
|
try {
|
|
276
|
-
|
|
277
|
-
const
|
|
278
|
-
await client.
|
|
279
|
-
port: cdpOpts.port ?? 8081,
|
|
280
|
-
deviceId: sessionName !== 'default' ? sessionName : undefined,
|
|
281
|
-
platform,
|
|
282
|
-
targetIndex: cdpOpts.targetIndex,
|
|
283
|
-
});
|
|
284
|
-
const read = await client.evaluate(REACT_PROFILER_READ(top));
|
|
285
|
-
await client.evaluate(REACT_PROFILER_STOP);
|
|
286
|
-
client.close();
|
|
561
|
+
client = await connectCdp(sessionName, reactOpts);
|
|
562
|
+
const read = await client.evaluate((0, exports.REACT_PROFILER_READ)(top, reactOpts.timeline ?? false));
|
|
563
|
+
await client.evaluate(exports.REACT_PROFILER_STOP);
|
|
287
564
|
if (!read.installed) {
|
|
288
565
|
(0, output_js_1.printError)('profile react stop — profiler was not installed', opts);
|
|
289
566
|
return 1;
|
|
290
567
|
}
|
|
291
|
-
if (
|
|
292
|
-
(0, output_js_1.
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
568
|
+
if (read.profilingSupported === false) {
|
|
569
|
+
(0, output_js_1.printError)(`profile react stop — ${NO_PROFILING_BUILD}`, opts);
|
|
570
|
+
return 1;
|
|
571
|
+
}
|
|
572
|
+
if (read.profilingSupported === null || (read.totalCommits ?? 0) === 0) {
|
|
573
|
+
(0, output_js_1.printError)('profile react stop — no commits were captured. Either nothing re-rendered while the ' +
|
|
574
|
+
'profiler was installed, or the app reloaded (which drops the hook).', opts);
|
|
575
|
+
return 1;
|
|
298
576
|
}
|
|
577
|
+
if (opts.json)
|
|
578
|
+
(0, output_js_1.printData)({ status: 'ok', ...read }, opts);
|
|
579
|
+
else
|
|
580
|
+
printReactReport(read);
|
|
299
581
|
return 0;
|
|
300
582
|
}
|
|
301
583
|
catch (err) {
|
|
302
584
|
(0, output_js_1.printError)(`profile react stop — ${err instanceof Error ? err.message : String(err)}`, opts);
|
|
303
585
|
return 1;
|
|
304
586
|
}
|
|
587
|
+
finally {
|
|
588
|
+
client?.close();
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
function printReactReport(read) {
|
|
592
|
+
console.log(`profile react — ${read.totalCommits ?? 0} commit(s)`);
|
|
593
|
+
console.log(` ${'self'.padStart(9)} ${'total'.padStart(9)} renders component`);
|
|
594
|
+
for (const t of read.top ?? []) {
|
|
595
|
+
console.log(` ${`${t.selfMs.toFixed(1)}ms`.padStart(9)} ${`${t.totalMs.toFixed(1)}ms`.padStart(9)} ` +
|
|
596
|
+
`${String(t.renders).padStart(7)} ${t.name}`);
|
|
597
|
+
}
|
|
598
|
+
console.log(' self is additive across components; total is subtree-inclusive and double-counts parents.');
|
|
599
|
+
const commits = read.commits ?? [];
|
|
600
|
+
if (commits.length > 0) {
|
|
601
|
+
const slowest = [...commits].sort((a, b) => b.durationMs - a.durationMs).slice(0, 5);
|
|
602
|
+
const t0 = commits[0].at;
|
|
603
|
+
console.log('\n slowest commits');
|
|
604
|
+
for (const c of slowest) {
|
|
605
|
+
console.log(` t+${((c.at - t0) / 1000).toFixed(2)}s ${c.durationMs.toFixed(1)}ms ` +
|
|
606
|
+
`${c.componentCount} component(s)`);
|
|
607
|
+
}
|
|
608
|
+
console.log(' full per-commit timeline is in --json output (add --timeline for components).');
|
|
609
|
+
}
|
|
610
|
+
if ((read.skippedCommits ?? 0) > 0) {
|
|
611
|
+
console.log(`\n skipped ${read.skippedCommits} commit(s) whose root fiber carried no render start ` +
|
|
612
|
+
'stamp — their work could not be told apart from earlier renders.');
|
|
613
|
+
}
|
|
614
|
+
if (read.truncated) {
|
|
615
|
+
console.log(`\n TRUNCATED: dropped ${read.droppedCommits ?? 0} commit(s) beyond --max-commits ` +
|
|
616
|
+
`${read.maxCommits} and ${read.droppedComponents ?? 0} component record(s) beyond ` +
|
|
617
|
+
`--max-components ${read.maxComponents}. Raise those limits or profile a shorter window.`);
|
|
618
|
+
}
|
|
305
619
|
}
|