Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 62 additions & 3 deletions src/cli/react-profiler/react-profile-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,20 @@ test('profile start, an interaction, and profile stop print a compact diagnosis
'1. memo-defeated Row: memo bypassed on 36/36 updates; new onPress (function) on those updates from List -> wrap those props in useCallback/useMemo in List',
);
assert.ok(fs.existsSync(path.join(stateDir, 'react-profiles', 'qa.json')));

const report = await run('report', 'Row');
assert.match(
report.text,
/^Row \[memo\] self 108ms avg 3\.0ms max 3\.0ms renders 36 12 inst\ncauses: props 36\nchanged props: onPress 36 \(function\)/,
);
assert.match(
(await run('timeline', '--sort', 'duration', '--limit', '1')).text,
/^Slowest commits \(1 of 3\):\n {2}#0 \+0\.0s 40ms 13 rendered <- List/,
);
assert.match(
(await run('commit', '#1')).text,
/^#1 \+0\.2s 40ms 13 rendered <- List\n {2}Row x12 self 36ms props/,
);
});

test('profile stop --json returns the findings and where the profile was saved', async () => {
Expand Down Expand Up @@ -182,6 +196,10 @@ test('runtime failures carry a typed reason and a hint', async () => {
);
});

test('drill-down views need a saved profile', async () => {
await assert.rejects(harness().run('slow'), /No React profile saved for session qa/);
});

test('explicit dev-server flags override the session binding', async () => {
const { run, requested } = harness();
await run('status', '--metro-port', '8082');
Expand Down Expand Up @@ -209,15 +227,29 @@ test('a remote bridge profiles through the public dev-server URL without its bun
assert.deepEqual(requested, ['https://metro.example.test/json/list']);
});

test('a corrupt saved profile asks for a new recording', async () => {
const { run, stateDir } = harness();
fs.mkdirSync(path.join(stateDir, 'react-profiles'), { recursive: true });
const file = path.join(stateDir, 'react-profiles', 'qa.json');
fs.writeFileSync(file, '{"analysis":');
await assert.rejects(run('slow'), /unsupported format/);
fs.writeFileSync(file, JSON.stringify({ analysis: { version: 1, components: {} } }));
await assert.rejects(run('slow'), /unsupported format/);
});

test('unknown profile commands and malformed options are invalid arguments', async () => {
const { run } = harness();
const cases: Array<[string[], RegExp]> = [
[['export', 'out.json'], /Unknown react-devtools profile command: export/],
[['stop', '--limit', '0'], /Invalid --limit: 0/],
[['stop', '--limit', '10garbage'], /Invalid --limit: 10garbage/],
[['stop', '--limit', '1.5'], /Invalid --limit: 1\.5/],
[['slow', '--limit', '0'], /Invalid --limit: 0/],
[['slow', '--limit', '10garbage'], /Invalid --limit: 10garbage/],
[['slow', '--limit', '1.5'], /Invalid --limit: 1\.5/],
[['timeline', '--sort', 'name'], /Invalid --sort: name/],
[['stop', 'extra'], /Unexpected argument for react-devtools profile stop: extra/],
[['start', '--limit', '5'], /react-devtools profile start does not take --limit/],
[['report', 'Row', 'extra'], /Unexpected argument for react-devtools profile report: extra/],
[['report', 'Row', '--limit', '2'], /react-devtools profile report does not take --limit/],
[['slow', '--sort', 'duration'], /react-devtools profile slow does not take --sort/],
];
for (const [args, message] of cases) {
await assert.rejects(run(...args), (error: { code: string; message: string }) => {
Expand All @@ -227,3 +259,30 @@ test('unknown profile commands and malformed options are invalid arguments', asy
});
}
});

test('profile commit --json honors --limit like the text view', async () => {
const { app, run, options } = harness();
await run('start');
recordRowRenders(app);
await run('stop');
options.flags = { json: true };
const { text } = await run('commit', '1', '--limit', '1');
const parsed = JSON.parse(text) as { data: { top: Array<{ name: string }>; rendered: number } };
assert.deepEqual(
parsed.data.top.map((entry) => entry.name),
['Row'],
);
assert.equal(parsed.data.rendered, 13);
});

test('commit selectors must be whole indexes', async () => {
const { app, run } = harness();
await run('start');
recordRowRenders(app);
await run('stop');
await assert.rejects(
run('commit', '#1junk'),
(error: { code: string }) => error.code === 'INVALID_ARGS',
);
assert.match((await run('commit', '#1')).text, /^#1 /);
});
133 changes: 128 additions & 5 deletions src/cli/react-profiler/react-profile-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,35 @@ import {
import { analyzeReactProfile, type ReactProfileAnalysis } from './react-profile-analysis.ts';
import {
selectPropKindRequests,
type CommitProfile,
type ComponentProfile,
type PropKindsById,
type RawReactProfile,
} from './react-profile-renders.ts';
import {
componentsByRenders,
DEFAULT_FINDING_LIMIT,
formatProfileSummary,
findComponent,
formatSeconds,
formatCommit,
formatComponentReport,
formatProfileSummary,
formatRerenders,
formatSlowComponents,
formatTimeline,
timelineCommits,
} from './react-profile-format.ts';

const REACT_PROFILE_SUBCOMMANDS = ['start', 'stop', 'status'] as const;
const REACT_PROFILE_SUBCOMMANDS = [
'start',
'stop',
'status',
'slow',
'rerenders',
'report',
'timeline',
'commit',
] as const;

type ReactProfileSubcommand = (typeof REACT_PROFILE_SUBCOMMANDS)[number];

Expand All @@ -43,15 +62,17 @@ export type ReactProfileCommandOptions = {

type ProfileArgs = {
subcommand: ReactProfileSubcommand;
target?: string;
limit?: number;
app?: string;
metroDevice?: string;
metroHost?: string;
metroPort?: string;
bundleUrl?: string;
sort?: 'timeline' | 'duration';
};

/** Analysis plus the runtime it came from, as `profile stop` saves it for the session. */
/** Analysis plus the runtime it came from, as `profile stop` saves it for the drill-down views. */
type SavedReactProfile = {
savedAt: string;
appId?: string;
Expand All @@ -64,6 +85,7 @@ type StartResult = { ok: true; attach: string } | RuntimeFailure;
type StopResult = ({ ok: true } & RawReactProfile) | RuntimeFailure;
type StatusResult = { profiling: boolean; elapsedMs: number | null };

const SUBCOMMANDS_WITH_TARGET = new Set<ReactProfileSubcommand>(['report', 'commit']);
const POSITIVE_INTEGER = /^[1-9]\d*$/;

function isReactProfileSubcommand(value: string | undefined): value is ReactProfileSubcommand {
Expand All @@ -77,6 +99,7 @@ const PROFILE_OPTIONS = {
'metro-host': { type: 'string' },
'metro-port': { type: 'string' },
'bundle-url': { type: 'string' },
sort: { type: 'string' },
} as const;

type ProfileOption = keyof typeof PROFILE_OPTIONS;
Expand All @@ -94,6 +117,11 @@ const SUBCOMMAND_OPTIONS: Record<ReactProfileSubcommand, readonly ProfileOption[
start: RUNTIME_OPTIONS,
status: RUNTIME_OPTIONS,
stop: [...RUNTIME_OPTIONS, 'limit'],
slow: ['limit'],
rerenders: ['limit'],
report: [],
timeline: ['limit', 'sort'],
commit: ['limit'],
};

function parseProfileOptions(args: string[]) {
Expand Down Expand Up @@ -123,24 +151,30 @@ function parseProfileArgs(args: string[]): ProfileArgs {
`react-devtools profile ${subcommand} does not take --${unread}.`,
);
}
if (positionals.length > 0) {
const expected = SUBCOMMANDS_WITH_TARGET.has(subcommand) ? 1 : 0;
if (positionals.length > expected) {
throw new AppError(
'INVALID_ARGS',
`Unexpected argument for react-devtools profile ${subcommand}: ${positionals[0]}.`,
`Unexpected argument for react-devtools profile ${subcommand}: ${positionals[expected]}.`,
);
}
if (values.limit !== undefined && !POSITIVE_INTEGER.test(values.limit)) {
throw new AppError('INVALID_ARGS', `Invalid --limit: ${values.limit}. Use a positive integer.`);
}
const limit = values.limit === undefined ? undefined : Number(values.limit);
if (values.sort !== undefined && values.sort !== 'timeline' && values.sort !== 'duration') {
throw new AppError('INVALID_ARGS', `Invalid --sort: ${values.sort}. Use timeline or duration.`);
}
return {
subcommand,
target: positionals[0],
limit,
app: values.app,
metroDevice: values['metro-device'],
metroHost: values['metro-host'],
metroPort: values['metro-port'],
bundleUrl: values['bundle-url'],
sort: values.sort as ProfileArgs['sort'],
};
}

Expand Down Expand Up @@ -250,6 +284,39 @@ function writeSavedProfile(
return filePath;
}

function isSavedProfile(value: SavedReactProfile | undefined): value is SavedReactProfile {
const analysis = value?.analysis;
return (
analysis?.version === 1 &&
Array.isArray(analysis.components) &&
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Array.isArray(analysis.commits) &&
Array.isArray(analysis.findings)
);
}

function readSavedProfile(options: ReactProfileCommandOptions): SavedReactProfile {
let raw: string;
try {
raw = fs.readFileSync(savedProfilePath(options), 'utf8');
} catch {
throw new AppError('COMMAND_FAILED', `No React profile saved for session ${options.session}.`, {
hint: 'Run react-devtools profile start, perform the interaction, then react-devtools profile stop.',
});
}
let saved: SavedReactProfile | undefined;
try {
saved = JSON.parse(raw) as SavedReactProfile;
} catch {
saved = undefined;
}
if (!isSavedProfile(saved)) {
throw new AppError('COMMAND_FAILED', 'The saved React profile has an unsupported format.', {
hint: 'Record a new profile with react-devtools profile start and stop.',
});
}
return saved;
}

const elapsed = (elapsedMs: number) => formatSeconds(Math.max(0, elapsedMs));

async function startProfile(
Expand Down Expand Up @@ -328,6 +395,32 @@ type SubcommandHandler = (
emit: Emit,
) => Promise<void>;

function requireComponent(
analysis: ReactProfileAnalysis,
name: string | undefined,
): ComponentProfile {
if (!name)
throw new AppError('INVALID_ARGS', 'react-devtools profile report requires a component name.');
const component = findComponent(analysis, name);
if (!component) {
throw new AppError('COMMAND_FAILED', `No renders of ${name} in the saved profile.`, {
hint: 'Use a name from react-devtools profile slow or profile rerenders.',
});
}
return component;
}

function requireCommit(analysis: ReactProfileAnalysis, target: string | undefined): CommitProfile {
const digits = (target ?? '').replace(/^#/, '');
const commit = /^\d+$/.test(digits) ? analysis.commits[Number(digits)] : undefined;
if (!commit) {
throw new AppError('INVALID_ARGS', `No commit ${target ?? '(none)'} in the saved profile.`, {
hint: `Use a commit index from 0 to ${Math.max(analysis.commits.length - 1, 0)}.`,
});
}
return commit;
}

const SUBCOMMAND_HANDLERS: Record<ReactProfileSubcommand, SubcommandHandler> = {
start: async (args, options, emit) => {
const started = await startProfile(args, options);
Expand All @@ -354,6 +447,36 @@ const SUBCOMMAND_HANDLERS: Record<ReactProfileSubcommand, SubcommandHandler> = {
savedTo: savedProfilePath(options),
});
},
slow: async (args, options, emit) => {
const { analysis } = readSavedProfile(options);
const limit = args.limit ?? 10;
emit(formatSlowComponents(analysis, limit), analysis.components.slice(0, limit));
},
rerenders: async (args, options, emit) => {
const { analysis } = readSavedProfile(options);
const limit = args.limit ?? 10;
emit(formatRerenders(analysis, limit), componentsByRenders(analysis).slice(0, limit));
},
report: async (args, options, emit) => {
const { analysis } = readSavedProfile(options);
const component = requireComponent(analysis, args.target);
emit(formatComponentReport(analysis, component), component);
},
timeline: async (args, options, emit) => {
const { analysis } = readSavedProfile(options);
const limit = args.limit ?? 20;
const byDuration = args.sort === 'duration';
emit(
formatTimeline(analysis, limit, byDuration),
timelineCommits(analysis, byDuration).slice(0, limit),
);
},
commit: async (args, options, emit) => {
const { analysis } = readSavedProfile(options);
const commit = requireCommit(analysis, args.target);
const limit = args.limit ?? 10;
emit(formatCommit(commit, limit), { ...commit, top: commit.top.slice(0, limit) });
},
};

export async function runReactProfileCommand(
Expand Down
Loading
Loading