Skip to content

Commit 0ffa432

Browse files
authored
feat(core): emit agent-readable timing summary for --trace (#1355)
1 parent 8f1deca commit 0ffa432

7 files changed

Lines changed: 480 additions & 18 deletions

File tree

packages/core/src/cli/commands.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,10 @@ const runtimeOptionDefinitions: OptionDefinition[] = [
9898
['--disableConsoleIntercept', 'Disable console intercept'],
9999
['--logHeapUsage', 'Log heap usage after each test'],
100100
['--detectAsyncLeaks', 'Detect async resources that leak after tests finish'],
101-
['--trace', 'Dump a Perfetto-compatible performance trace JSON file'],
101+
[
102+
'--trace',
103+
'Dump a Perfetto-compatible performance trace JSON file, plus a ranked markdown timing summary printed to the terminal and written next to it',
104+
],
102105
[
103106
'--slowTestThreshold <value>',
104107
'The number of milliseconds after which a test or suite is considered slow',

packages/core/src/utils/helper.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
1-
import { isAbsolute, join, normalize, parse, sep } from 'pathe';
1+
import {
2+
isAbsolute,
3+
join,
4+
normalize,
5+
parse,
6+
relative,
7+
resolve,
8+
sep,
9+
} from 'pathe';
210
import type { RuntimeConfig, TestResult } from '../types';
311
import { TEST_DELIMITER } from './constants';
412
import { color } from './logger';
@@ -41,6 +49,17 @@ export function getAbsolutePath(base: string, filepath: string): string {
4149
return isAbsolute(filepath) ? filepath : join(base, filepath);
4250
}
4351

52+
/**
53+
* Render a path relative to `rootPath` when it lives inside the root, otherwise
54+
* fall back to the original path. Used for trace labels and summary tables so
55+
* in-repo files show as short relative paths while external/sentinel paths
56+
* (e.g. `<host>`) pass through unchanged.
57+
*/
58+
export const displayPath = (filePath: string, rootPath: string): string => {
59+
const rel = relative(rootPath, resolve(rootPath, filePath));
60+
return rel && !rel.startsWith('..') ? rel : filePath;
61+
};
62+
4463
export const parsePosix = (filePath: string): { dir: string; base: string } => {
4564
const { dir, base } = parse(filePath);
4665

packages/core/src/utils/trace.ts

Lines changed: 41 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
22
import { createServer } from 'node:http';
3-
import { dirname, relative, resolve } from 'pathe';
4-
import { isTTY } from './helper';
3+
import { dirname, resolve } from 'pathe';
4+
import { displayPath, isTTY } from './helper';
55
import { color, logger } from './logger';
6+
import { formatTraceSummary, summarizeTrace } from './traceSummary';
67

78
// ---------------------------------------------------------------------------
89
// Public event type
@@ -46,12 +47,17 @@ export const noopTraceSpan: TraceSpan = async (_name, _cat, fn) => fn();
4647
// ---------------------------------------------------------------------------
4748

4849
/**
49-
* Build the absolute path for a Perfetto trace dump. The filename embeds an
50-
* ISO-like timestamp so repeated runs do not overwrite each other.
50+
* Build the absolute paths for a run's artifacts: the Perfetto trace dump and
51+
* its markdown summary sidecar. Both share one timestamped stem so the pair is
52+
* named from a single source (no fragile extension rewriting), and the stamp
53+
* keeps repeated runs from overwriting each other.
5154
*/
52-
const getTraceOutputPath = (rootPath: string): string => {
55+
const getTraceOutputPaths = (
56+
rootPath: string,
57+
): { tracePath: string; summaryPath: string } => {
5358
const stamp = new Date().toISOString().replace(/[:.]/g, '-').replace('Z', '');
54-
return resolve(rootPath, '.rstest', `trace-${stamp}.json`);
59+
const stem = resolve(rootPath, '.rstest', `trace-${stamp}`);
60+
return { tracePath: `${stem}.json`, summaryPath: `${stem}.summary.md` };
5561
};
5662

5763
/**
@@ -100,11 +106,6 @@ const buildTraceFile = (
100106
threadIds.add(ev.tid);
101107
}
102108

103-
const displayPath = (testPath: string): string => {
104-
const rel = relative(rootPath, testPath);
105-
return rel && !rel.startsWith('..') ? rel : testPath;
106-
};
107-
108109
const seenPid = new Set<number>();
109110
const seenThread = new Set<string>();
110111
const metadata: TraceEvent[] = [];
@@ -119,7 +120,9 @@ const buildTraceFile = (
119120
const sharedWorker = (threadIdsByPid.get(ev.pid)?.size ?? 0) > 1;
120121
metadata.push(
121122
meta('process_name', ev.pid, ev.tid, {
122-
name: sharedWorker ? `worker ${ev.pid}` : displayPath(testPath),
123+
name: sharedWorker
124+
? `worker ${ev.pid}`
125+
: displayPath(testPath, rootPath),
123126
}),
124127
meta('process_sort_index', ev.pid, ev.tid, {
125128
sort_index: sortIndex++,
@@ -131,7 +134,9 @@ const buildTraceFile = (
131134
if (!seenThread.has(threadKey)) {
132135
seenThread.add(threadKey);
133136
metadata.push(
134-
meta('thread_name', ev.pid, ev.tid, { name: displayPath(testPath) }),
137+
meta('thread_name', ev.pid, ev.tid, {
138+
name: displayPath(testPath, rootPath),
139+
}),
135140
);
136141
}
137142
}
@@ -300,6 +305,9 @@ export const createTraceController = (options: {
300305
// In watch mode we replace it on each rerun so .rstest/ does not accumulate
301306
// multi-MB JSONs; files from earlier sessions are left alone.
302307
let lastTracePath: string | undefined;
308+
// Sidecar `.summary.md` produced alongside the trace; replaced on rerun in
309+
// watch mode for the same reason as `lastTracePath`.
310+
let lastSummaryPath: string | undefined;
303311

304312
const beginRun = (): TraceRun => {
305313
if (!enabled) {
@@ -343,7 +351,7 @@ export const createTraceController = (options: {
343351
span: pushHostSlice,
344352
finalize: async () => {
345353
if (!events.length) return;
346-
const tracePath = getTraceOutputPath(rootPath);
354+
const { tracePath, summaryPath } = getTraceOutputPaths(rootPath);
347355
await mkdir(dirname(tracePath), { recursive: true });
348356
await writeFile(
349357
tracePath,
@@ -354,10 +362,29 @@ export const createTraceController = (options: {
354362
unlink(lastTracePath).catch(() => {});
355363
}
356364
lastTracePath = tracePath;
365+
366+
// Agent/CI-friendly text summary: the Perfetto JSON is a raw event
367+
// dump meant for the visual UI, so always emit a ranked markdown
368+
// digest (printed to stdout and written next to the trace) that
369+
// answers "where did time go" without opening a flame graph.
370+
const summaryMarkdown = formatTraceSummary(
371+
summarizeTrace(events, rootPath),
372+
);
373+
await writeFile(summaryPath, `${summaryMarkdown}\n`);
374+
if (lastSummaryPath && lastSummaryPath !== summaryPath) {
375+
unlink(lastSummaryPath).catch(() => {});
376+
}
377+
lastSummaryPath = summaryPath;
378+
379+
logger.log(`\n${summaryMarkdown}\n`);
357380
logger.log(
358381
color.gray(' Perfetto trace file: '),
359382
color.cyan(tracePath),
360383
);
384+
logger.log(
385+
color.gray(' Trace summary file: '),
386+
color.cyan(summaryPath),
387+
);
361388
// The helper server keeps the event loop alive until SIGINT, which
362389
// would hang `rstest run` in CI. Only start it in an interactive TTY,
363390
// otherwise leave the file for the user to download from the CI

0 commit comments

Comments
 (0)