Skip to content

Commit 471673b

Browse files
authored
feat(core): add --trace flag to dump Perfetto-compatible profile (#1226)
1 parent 906506a commit 471673b

19 files changed

Lines changed: 813 additions & 19 deletions

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
.DS_Store
22
*.log*
33
*.cpuprofile
4+
*.dump
45
node_modules/
56

67
.cache/
@@ -48,6 +49,7 @@ packages/vscode/*.vsix
4849
packages/vscode/icon.png
4950

5051
.rstest-temp/
52+
.rstest/
5153
lefthook-local.yml
5254

5355
# Generated memory benchmark fixture

packages/browser/src/hostController.ts

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
type ListCommandResult,
1919
loadCoverageProvider,
2020
logger,
21+
PhaseTracker,
2122
type ProjectContext,
2223
type Reporter,
2324
type Rstest,
@@ -95,6 +96,18 @@ type RsbuildInstance = rsbuild.RsbuildInstance;
9596
const __dirname = dirname(fileURLToPath(import.meta.url));
9697
const OPTIONS_PLACEHOLDER = '__RSTEST_OPTIONS_PLACEHOLDER__';
9798

99+
/**
100+
* Monotonic counter for synthetic per-file Perfetto `pid` values in `--trace`
101+
* mode. Browser host runs every test file inside the same Node process, so
102+
* without an override every file would emit events under the same `pid` and
103+
* share a single track labelled `worker <hostPid>`. Giving each file its own
104+
* synthetic `pid` makes the track title surface the file path instead,
105+
* matching node mode's default `isolate: true` behavior. The 1_000_000_000
106+
* base keeps each synthetic `pid` well clear of real OS `pid` values in
107+
* mixed-mode traces.
108+
*/
109+
let nextBrowserFilePid = 1_000_000_000;
110+
98111
/**
99112
* Serialize JSON for inline <script> injection.
100113
* Escapes '<' to prevent accidental </script> break-out.
@@ -1693,10 +1706,22 @@ export const runBrowserController = async (
16931706
context: Rstest,
16941707
options?: BrowserTestRunOptions,
16951708
): Promise<BrowserTestRunResult | void> => {
1696-
const { skipOnTestRunEnd = false, allowEmptyWatchRun = false } =
1697-
options ?? {};
1709+
const {
1710+
skipOnTestRunEnd = false,
1711+
allowEmptyWatchRun = false,
1712+
onTraceEvents,
1713+
} = options ?? {};
16981714
const buildStart = Date.now();
16991715
const isWatchMode = context.command === 'watch';
1716+
1717+
// Per-file PhaseTrackers, populated only when `--trace` is on (caller
1718+
// passes `onTraceEvents`). The browser host shares one Node process across
1719+
// every test file, so each tracker is assigned a synthetic per-file pid
1720+
// (`nextBrowserFilePid`) that lets Perfetto render each file as its own
1721+
// process track with the file path as the title.
1722+
const phaseTrackers = onTraceEvents
1723+
? new Map<string, PhaseTracker>()
1724+
: undefined;
17001725
const browserProjects = getBrowserProjects(context);
17011726
const useHeadlessDirect = browserProjects.every(
17021727
(project) => project.normalizedConfig.browser.headless,
@@ -2180,6 +2205,17 @@ export const runBrowserController = async (
21802205
const handleTestFileStart = async (
21812206
payload: TestFileStartPayload,
21822207
): Promise<void> => {
2208+
if (phaseTrackers) {
2209+
const tracker = new PhaseTracker({
2210+
trace: {
2211+
testPath: payload.testPath,
2212+
project: payload.projectName,
2213+
},
2214+
pid: nextBrowserFilePid++,
2215+
});
2216+
tracker.transition('prepare');
2217+
phaseTrackers.set(payload.testPath, tracker);
2218+
}
21832219
await Promise.all(
21842220
context.reporters.map((reporter) =>
21852221
(reporter as Reporter).onTestFileStart?.({
@@ -2194,6 +2230,7 @@ export const runBrowserController = async (
21942230
const handleTestFileReady = async (
21952231
payload: TestFileReadyPayload,
21962232
): Promise<void> => {
2233+
phaseTrackers?.get(payload.testPath)?.transition('tests');
21972234
await Promise.all(
21982235
context.reporters.map((reporter) =>
21992236
(reporter as Reporter).onTestFileReady?.(payload),
@@ -2204,6 +2241,7 @@ export const runBrowserController = async (
22042241
const handleTestSuiteStart = async (
22052242
payload: TestSuiteStartPayload,
22062243
): Promise<void> => {
2244+
phaseTrackers?.get(payload.testPath)?.recordSuiteStart(payload);
22072245
await Promise.all(
22082246
context.reporters.map((reporter) =>
22092247
(reporter as Reporter).onTestSuiteStart?.(payload),
@@ -2214,6 +2252,7 @@ export const runBrowserController = async (
22142252
const handleTestSuiteResult = async (
22152253
payload: TestSuiteResultPayload,
22162254
): Promise<void> => {
2255+
phaseTrackers?.get(payload.testPath)?.recordSuiteResult(payload);
22172256
await Promise.all(
22182257
context.reporters.map((reporter) =>
22192258
(reporter as Reporter).onTestSuiteResult?.(payload),
@@ -2234,6 +2273,7 @@ export const runBrowserController = async (
22342273
const handleTestCaseStart = async (
22352274
payload: TestCaseStartPayload,
22362275
): Promise<void> => {
2276+
phaseTrackers?.get(payload.testPath)?.recordCaseStart(payload);
22372277
await Promise.all(
22382278
context.reporters.map((reporter) =>
22392279
(reporter as Reporter).onTestCaseStart?.(payload),
@@ -2243,6 +2283,7 @@ export const runBrowserController = async (
22432283

22442284
const handleTestCaseResult = async (payload: TestResult): Promise<void> => {
22452285
caseResults.push(payload);
2286+
phaseTrackers?.get(payload.testPath)?.recordCaseResult(payload);
22462287
await Promise.all(
22472288
context.reporters.map((reporter) =>
22482289
(reporter as Reporter).onTestCaseResult?.(payload),
@@ -2269,6 +2310,16 @@ export const runBrowserController = async (
22692310
context.snapshotManager.add(payload.snapshotResult);
22702311
}
22712312

2313+
if (phaseTrackers) {
2314+
const tracker = phaseTrackers.get(payload.testPath);
2315+
if (tracker) {
2316+
tracker.end();
2317+
const events = tracker.getTraceEvents();
2318+
if (events) onTraceEvents?.(events);
2319+
phaseTrackers.delete(payload.testPath);
2320+
}
2321+
}
2322+
22722323
if (context.normalizedConfig.silent === 'passed-only') {
22732324
await flushBufferedLogsForTask({
22742325
taskId: payload.testId,

packages/core/src/browser.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ export { createRstestRuntime } from './runtime/api';
1717
// These are the test APIs that run in the browser (describe, it, expect, etc.)
1818
export * from './runtime/api/public';
1919
export { setRealTimers } from './runtime/util';
20+
// Trace primitives — the browser host instantiates PhaseTracker per test file
21+
// and forwards its events via `BrowserTestRunOptions.onTraceEvents`.
22+
export { PhaseTracker } from './runtime/worker/phaseTracker';
23+
export type { TraceEvent } from './utils/trace';
2024
// Types
2125
export type {
2226
BrowserTestRunOptions,

packages/core/src/cli/commands.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ const runtimeOptionDefinitions: OptionDefinition[] = [
6767
],
6868
['--disableConsoleIntercept', 'Disable console intercept'],
6969
['--logHeapUsage', 'Log heap usage after each test'],
70+
['--trace', 'Dump a Perfetto-compatible performance trace JSON file'],
7071
[
7172
'--slowTestThreshold <value>',
7273
'The number of milliseconds after which a test or suite is considered slow',
@@ -321,7 +322,7 @@ export const runRest = async ({
321322
});
322323

323324
rstest = createRstest(
324-
{ config, configFilePath, projects },
325+
{ config, configFilePath, projects, trace: options.trace },
325326
command,
326327
effectiveFilters,
327328
fileFilterMode,

packages/core/src/cli/init.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ export type CommonOptions = {
6767
silent?: boolean | 'passed-only';
6868
printConsoleTrace?: boolean;
6969
logHeapUsage?: boolean;
70+
trace?: boolean;
7071
disableConsoleIntercept?: boolean;
7172
update?: boolean;
7273
testNamePattern?: RegExp | string;

packages/core/src/core/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,13 @@ export function createRstest(
1313
config,
1414
projects,
1515
configFilePath,
16+
trace,
1617
}: {
1718
config: RstestConfig;
1819
configFilePath?: string;
1920
projects: Project[];
21+
/** CLI-only `--trace` switch; not exposed via user config. */
22+
trace?: boolean;
2023
},
2124
command: RstestCommand,
2225
fileFilters: string[],
@@ -30,6 +33,7 @@ export function createRstest(
3033
fileFilterMode,
3134
configFilePath,
3235
projects,
36+
trace,
3337
},
3438
config,
3539
);

packages/core/src/core/rstest.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ type Options = {
4949
fileFilterMode?: FileFilterMode;
5050
configFilePath?: string;
5151
projects: Project[];
52+
trace?: boolean;
5253
};
5354

5455
export class Rstest implements RstestContext {
@@ -61,6 +62,7 @@ export class Rstest implements RstestContext {
6162
public configFilePath?: string;
6263
public reporters: Reporter[];
6364
public snapshotManager: SnapshotManager;
65+
public trace: boolean;
6466
public version: string;
6567
public rootPath: string;
6668
public originalConfig: RstestConfig;
@@ -96,11 +98,13 @@ export class Rstest implements RstestContext {
9698
fileFilterMode,
9799
configFilePath,
98100
projects,
101+
trace = false,
99102
}: Options,
100103
userConfig: RstestConfig,
101104
) {
102105
this.cwd = cwd;
103106
this.command = command;
107+
this.trace = trace;
104108
this.fileFilters = fileFilters;
105109
this.fileFilterMode = fileFilterMode;
106110
this.configFilePath = configFilePath;

0 commit comments

Comments
 (0)