zeitzeuge 0.9.0 → 0.10.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/dist/cli.js +696 -15
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -747,7 +747,7 @@ When your analysis is complete, you MUST write ALL findings to a JSON file using
|
|
|
747
747
|
|
|
748
748
|
1. Call write_file with path: \`/findings/<YOUR_AGENT_NAME>.json\`
|
|
749
749
|
Use your agent name as the filename (e.g. memory-heap, page-load, runtime-blocking,
|
|
750
|
-
code-pattern, cpu-hotspot, listener-leak, memory-closure).
|
|
750
|
+
code-pattern, rendering-fcp, cpu-hotspot, listener-leak, memory-closure).
|
|
751
751
|
|
|
752
752
|
2. The file content MUST be a JSON object with this exact structure:
|
|
753
753
|
{ "findings": [ { "severity": "...", "title": "...", ... }, ... ] }
|
|
@@ -766,7 +766,9 @@ var STRUCTURED_OUTPUT_FIELDS = `## Structured output fields — REQUIRED for eve
|
|
|
766
766
|
|
|
767
767
|
Every finding MUST include ALL of these fields:
|
|
768
768
|
|
|
769
|
-
- \`sourceFile\` — (REQUIRED) the workspace path
|
|
769
|
+
- \`sourceFile\` — (REQUIRED) the EXACT workspace path you used with read_file
|
|
770
|
+
(e.g. /scripts/app.js, /styles/theme.css, /html/index).
|
|
771
|
+
NEVER use bare filenames, original URLs, or paths you did not read.
|
|
770
772
|
- \`lineNumber\` — (REQUIRED) the 1-based line number, verified by reading the file
|
|
771
773
|
- \`confidence\` — \`high\` if you read the source, \`medium\` if strongly suggested,
|
|
772
774
|
\`low\` if inferred
|
|
@@ -2734,6 +2736,9 @@ function generateMarkdown(options) {
|
|
|
2734
2736
|
const reqCount = trace.networkRequests.length;
|
|
2735
2737
|
sections.push(`**Page load** ${loadSec}s · **FCP** ${fcpSec}s · **TBT** ${tbt}ms · ` + `**Heap** ${heapSize} · **${reqCount} requests** (${formatBytes(totalTransfer)} transferred)`);
|
|
2736
2738
|
sections.push("");
|
|
2739
|
+
if (trace.renderingDiagnostic) {
|
|
2740
|
+
sections.push(...generateFilmstripSection(trace.renderingDiagnostic, trace.screencastFrames ?? []));
|
|
2741
|
+
}
|
|
2737
2742
|
const counts = {
|
|
2738
2743
|
critical: findings.filter((f) => f.severity === "critical").length,
|
|
2739
2744
|
warning: findings.filter((f) => f.severity === "warning").length,
|
|
@@ -2819,6 +2824,78 @@ function generateMarkdown(options) {
|
|
|
2819
2824
|
return sections.join(`
|
|
2820
2825
|
`);
|
|
2821
2826
|
}
|
|
2827
|
+
var MAX_FILMSTRIP_FRAMES = 10;
|
|
2828
|
+
function selectKeyFrames(diagnostic, frames) {
|
|
2829
|
+
if (frames.length === 0)
|
|
2830
|
+
return [];
|
|
2831
|
+
const changeIndices = new Set(diagnostic.visualChanges.map((vc) => vc.frameIndex));
|
|
2832
|
+
changeIndices.add(0);
|
|
2833
|
+
changeIndices.add(frames.length - 1);
|
|
2834
|
+
const selected = [...changeIndices].sort((a, b) => a - b).filter((i) => i >= 0 && i < frames.length).map((i) => frames[i]);
|
|
2835
|
+
if (selected.length <= MAX_FILMSTRIP_FRAMES)
|
|
2836
|
+
return selected;
|
|
2837
|
+
const step = Math.ceil(selected.length / MAX_FILMSTRIP_FRAMES);
|
|
2838
|
+
const sampled = [];
|
|
2839
|
+
for (let i = 0;i < selected.length; i += step) {
|
|
2840
|
+
sampled.push(selected[i]);
|
|
2841
|
+
}
|
|
2842
|
+
if (sampled[sampled.length - 1] !== selected[selected.length - 1]) {
|
|
2843
|
+
sampled.push(selected[selected.length - 1]);
|
|
2844
|
+
}
|
|
2845
|
+
return sampled.slice(0, MAX_FILMSTRIP_FRAMES);
|
|
2846
|
+
}
|
|
2847
|
+
function generateFilmstripSection(diagnostic, frames) {
|
|
2848
|
+
const sections = [];
|
|
2849
|
+
sections.push(`## Rendering Filmstrip`);
|
|
2850
|
+
sections.push("");
|
|
2851
|
+
const speedIdx = diagnostic.speedIndex;
|
|
2852
|
+
const fcpTs = diagnostic.fcpCorrelation.fcpTimestamp;
|
|
2853
|
+
sections.push(`**Speed Index** ${speedIdx}ms · **FCP** ${fcpTs}ms`);
|
|
2854
|
+
sections.push("");
|
|
2855
|
+
const keyFrames = selectKeyFrames(diagnostic, frames);
|
|
2856
|
+
if (keyFrames.length > 0) {
|
|
2857
|
+
const headerCells = keyFrames.map((f) => `${Math.round(f.timestamp)}ms`).join(" | ");
|
|
2858
|
+
const separatorCells = keyFrames.map(() => ":---:").join(" | ");
|
|
2859
|
+
const imageCells = keyFrames.map((f) => `<img src="data:image/jpeg;base64,${f.data}" width="120" alt="${Math.round(f.timestamp)}ms" />`).join(" | ");
|
|
2860
|
+
sections.push(`| ${headerCells} |`);
|
|
2861
|
+
sections.push(`| ${separatorCells} |`);
|
|
2862
|
+
sections.push(`| ${imageCells} |`);
|
|
2863
|
+
sections.push("");
|
|
2864
|
+
}
|
|
2865
|
+
if (diagnostic.visualChanges.length > 0) {
|
|
2866
|
+
sections.push(`### Visual Progress`);
|
|
2867
|
+
sections.push("");
|
|
2868
|
+
sections.push(`| Time | Visual Completeness |`);
|
|
2869
|
+
sections.push(`|------|:-------------------:|`);
|
|
2870
|
+
for (const vc of diagnostic.visualChanges) {
|
|
2871
|
+
const bar = "█".repeat(Math.round(vc.visualCompleteness / 5)) + "░".repeat(20 - Math.round(vc.visualCompleteness / 5));
|
|
2872
|
+
sections.push(`| ${Math.round(vc.timestamp)}ms | ${bar} ${vc.visualCompleteness}% |`);
|
|
2873
|
+
}
|
|
2874
|
+
sections.push("");
|
|
2875
|
+
}
|
|
2876
|
+
if (diagnostic.renderingPhases.length > 0) {
|
|
2877
|
+
sections.push(`### Rendering Phases`);
|
|
2878
|
+
sections.push("");
|
|
2879
|
+
sections.push(`| Phase | Duration | Description |`);
|
|
2880
|
+
sections.push(`|-------|----------|-------------|`);
|
|
2881
|
+
for (const phase of diagnostic.renderingPhases) {
|
|
2882
|
+
sections.push(`| ${phase.name} | ${phase.duration}ms | ${phase.description} |`);
|
|
2883
|
+
}
|
|
2884
|
+
sections.push("");
|
|
2885
|
+
}
|
|
2886
|
+
if (diagnostic.fcpBottlenecks.length > 0) {
|
|
2887
|
+
sections.push(`### FCP Bottlenecks`);
|
|
2888
|
+
sections.push("");
|
|
2889
|
+
sections.push(`| Type | Estimated Delay | Description |`);
|
|
2890
|
+
sections.push(`|------|:---------------:|-------------|`);
|
|
2891
|
+
for (const b of diagnostic.fcpBottlenecks) {
|
|
2892
|
+
const typeLabel = b.type.replace(/-/g, " ");
|
|
2893
|
+
sections.push(`| ${typeLabel} | ${b.estimatedDelayMs}ms | ${b.description} |`);
|
|
2894
|
+
}
|
|
2895
|
+
sections.push("");
|
|
2896
|
+
}
|
|
2897
|
+
return sections;
|
|
2898
|
+
}
|
|
2822
2899
|
// src/analysis/agent.ts
|
|
2823
2900
|
import { createDeepAgent as createDeepAgent2 } from "deepagents";
|
|
2824
2901
|
|
|
@@ -2832,7 +2909,7 @@ skill. Do NOT read data JSON files directly with read_file.
|
|
|
2832
2909
|
|
|
2833
2910
|
After your analysis scripts identify specific issues, read at most 1-3
|
|
2834
2911
|
source files that are directly implicated. Derive paths from script URLs
|
|
2835
|
-
in the data (e.g. a URL ending in "abc123.js" → scripts/abc123.js).
|
|
2912
|
+
in the data (e.g. a URL ending in "abc123.js" → /scripts/abc123.js).
|
|
2836
2913
|
|
|
2837
2914
|
PREFERRED actions:
|
|
2838
2915
|
- execute_command with pre-built helper scripts or custom Node.js scripts
|
|
@@ -2843,7 +2920,22 @@ FORBIDDEN actions:
|
|
|
2843
2920
|
- glob — NEVER call glob.
|
|
2844
2921
|
- read_file on data JSON files — use scripts to extract what you need.
|
|
2845
2922
|
- Reading ALL source files — only read the specific ones your scripts point to.
|
|
2846
|
-
- Reading more than 3 source files — focus on the most impactful issues
|
|
2923
|
+
- Reading more than 3 source files — focus on the most impactful issues.
|
|
2924
|
+
|
|
2925
|
+
## CRITICAL: sourceFile path format
|
|
2926
|
+
|
|
2927
|
+
When setting \`sourceFile\` in a finding, use the EXACT WORKSPACE PATH you
|
|
2928
|
+
used when calling read_file. Workspace paths start with a leading slash:
|
|
2929
|
+
- \`/scripts/App.tsx\` — JavaScript/TypeScript source files
|
|
2930
|
+
- \`/styles/theme.css\` — CSS source files
|
|
2931
|
+
- \`/html/index\` — HTML document
|
|
2932
|
+
|
|
2933
|
+
If you read a file at path \`/scripts/App.tsx\`, set \`sourceFile\` to exactly
|
|
2934
|
+
\`/scripts/App.tsx\`. Do NOT use bare filenames like \`App.tsx\`, original
|
|
2935
|
+
URLs like \`http://localhost:.../src/components/App.tsx\`, or made-up paths.
|
|
2936
|
+
|
|
2937
|
+
NEVER reference a file you did not actually read — only files you verified
|
|
2938
|
+
with read_file should appear in sourceFile.`;
|
|
2847
2939
|
var MINIFIED_SOURCE_HANDLING = `## Handling minified / compiled source files
|
|
2848
2940
|
|
|
2849
2941
|
The JavaScript files in scripts/ are captured from a PRODUCTION page. They are
|
|
@@ -3452,6 +3544,140 @@ Images without explicit width/height that cause Cumulative Layout Shift (CLS).
|
|
|
3452
3544
|
Check HTML and CSS completely. For scripts, focus on the ones that HTML/CSS
|
|
3453
3545
|
point to as potentially problematic. A typical page has 3-8 issues.
|
|
3454
3546
|
|
|
3547
|
+
${BROWSER_TOOL_CALL_STRATEGY}
|
|
3548
|
+
${VERIFICATION_RULES}
|
|
3549
|
+
${SEVERITY_RULES2}
|
|
3550
|
+
${FINDING_CATEGORIES}
|
|
3551
|
+
${OUTPUT_FORMAT}
|
|
3552
|
+
${STRUCTURED_OUTPUT_FIELDS}
|
|
3553
|
+
${MINIFIED_SOURCE_HANDLING}
|
|
3554
|
+
${IMPACT_ESTIMATION}
|
|
3555
|
+
${WRITE_FINDINGS_REQUIREMENT}`;
|
|
3556
|
+
// src/analysis/prompts/rendering-fcp.ts
|
|
3557
|
+
var RENDERING_FCP_PROMPT = `You are a specialist in analyzing rendering performance and First Contentful Paint (FCP) behavior.
|
|
3558
|
+
|
|
3559
|
+
You have access to a workspace with rendering diagnostic data captured via Chrome DevTools screencast, along with trace, network, and source files from a real page load.
|
|
3560
|
+
|
|
3561
|
+
## Your focus areas
|
|
3562
|
+
|
|
3563
|
+
### 1. FCP Bottleneck Analysis (HIGHEST PRIORITY)
|
|
3564
|
+
|
|
3565
|
+
Identify what delays First Contentful Paint — the moment the browser renders the first piece of DOM content (text, image, SVG, or non-white canvas).
|
|
3566
|
+
|
|
3567
|
+
\`\`\`html
|
|
3568
|
+
<!-- BAD: synchronous script in <head> blocks FCP -->
|
|
3569
|
+
<head>
|
|
3570
|
+
<script src="/heavy-analytics.js"></script>
|
|
3571
|
+
<link rel="stylesheet" href="/all-styles.css">
|
|
3572
|
+
</head>
|
|
3573
|
+
|
|
3574
|
+
<!-- GOOD: defer non-critical scripts, inline critical CSS -->
|
|
3575
|
+
<head>
|
|
3576
|
+
<style>/* critical above-fold CSS only */</style>
|
|
3577
|
+
<script src="/heavy-analytics.js" defer></script>
|
|
3578
|
+
<link rel="stylesheet" href="/all-styles.css" media="print" onload="this.media='all'">
|
|
3579
|
+
</head>
|
|
3580
|
+
\`\`\`
|
|
3581
|
+
|
|
3582
|
+
**How to detect:** Read /trace/rendering/fcp-diagnostic.json for pre-computed FCP bottlenecks with estimated delay times. Cross-reference with the render-blocking chain and main-thread blockers. For each bottleneck, read the corresponding source file to verify the root cause and propose a fix.
|
|
3583
|
+
|
|
3584
|
+
### 2. Rendering Order & Visual Progress
|
|
3585
|
+
|
|
3586
|
+
Analyze the order in which content appears on screen during page load. Identify elements that render late but should appear early (e.g. hero content, navigation, above-the-fold text).
|
|
3587
|
+
|
|
3588
|
+
\`\`\`javascript
|
|
3589
|
+
// BAD: hero image loaded lazily despite being above the fold
|
|
3590
|
+
<img src="hero.jpg" loading="lazy">
|
|
3591
|
+
|
|
3592
|
+
// GOOD: eager-load above-fold content, lazy-load below-fold
|
|
3593
|
+
<img src="hero.jpg" fetchpriority="high">
|
|
3594
|
+
<img src="below-fold.jpg" loading="lazy">
|
|
3595
|
+
\`\`\`
|
|
3596
|
+
|
|
3597
|
+
**How to detect:** Read /trace/rendering/visual-progress.json for the visual change timeline. Each visual change point shows when new content appeared and the estimated visual completeness. Correlate visual change timestamps with network-waterfall.json to identify which resources trigger which visual changes.
|
|
3598
|
+
|
|
3599
|
+
### 3. Render-Blocking Resource Chains
|
|
3600
|
+
|
|
3601
|
+
Sequential chains of render-blocking resources that compound FCP delay. Each resource in the chain must complete before the next starts, creating a waterfall that delays first render.
|
|
3602
|
+
|
|
3603
|
+
\`\`\`html
|
|
3604
|
+
<!-- BAD: CSS imports create sequential chains -->
|
|
3605
|
+
<!-- main.css: @import url('reset.css'); @import url('theme.css'); -->
|
|
3606
|
+
<link rel="stylesheet" href="/main.css">
|
|
3607
|
+
|
|
3608
|
+
<!-- GOOD: flatten CSS imports into parallel <link> tags -->
|
|
3609
|
+
<link rel="stylesheet" href="/reset.css">
|
|
3610
|
+
<link rel="stylesheet" href="/theme.css">
|
|
3611
|
+
<link rel="stylesheet" href="/main.css">
|
|
3612
|
+
\`\`\`
|
|
3613
|
+
|
|
3614
|
+
**How to detect:** The fcp-diagnostic.json file lists sequential resource chains. Read the CSS files to check for @import chains. Check HTML for script/stylesheet ordering that creates unnecessary serialization.
|
|
3615
|
+
|
|
3616
|
+
### 4. Speed Index & Visual Completeness
|
|
3617
|
+
|
|
3618
|
+
Speed Index measures how quickly the visible page area is filled with content. A high Speed Index means content renders slowly or in large bursts rather than progressively.
|
|
3619
|
+
|
|
3620
|
+
**How to detect:** Read /trace/rendering/visual-progress.json for the speedIndex value and the rendering phases. Identify phases with disproportionately long duration. Compare visual change frequency — long gaps between changes indicate rendering stalls.
|
|
3621
|
+
|
|
3622
|
+
### 5. Layout Thrashing Before FCP
|
|
3623
|
+
|
|
3624
|
+
Excessive layout recalculations during the critical rendering path that delay first paint.
|
|
3625
|
+
|
|
3626
|
+
\`\`\`javascript
|
|
3627
|
+
// BAD: script in <head> forces layout before FCP
|
|
3628
|
+
document.querySelectorAll('.hero').forEach(el => {
|
|
3629
|
+
el.style.height = el.offsetHeight * 2 + 'px'; // forced layout
|
|
3630
|
+
});
|
|
3631
|
+
|
|
3632
|
+
// GOOD: defer DOM manipulation to after DOMContentLoaded
|
|
3633
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
3634
|
+
requestAnimationFrame(() => {
|
|
3635
|
+
document.querySelectorAll('.hero').forEach(el => {
|
|
3636
|
+
el.style.height = el.offsetHeight * 2 + 'px';
|
|
3637
|
+
});
|
|
3638
|
+
});
|
|
3639
|
+
});
|
|
3640
|
+
\`\`\`
|
|
3641
|
+
|
|
3642
|
+
**How to detect:** The fcp-diagnostic.json includes layoutTimeBeforeFCP. If significant (>100ms), check trace/runtime/blocking-functions.json for Layout events before FCP and read the triggering scripts.
|
|
3643
|
+
|
|
3644
|
+
## Your workflow
|
|
3645
|
+
|
|
3646
|
+
1. In your FIRST turn, run BOTH of these:
|
|
3647
|
+
a. Run the workspace overview:
|
|
3648
|
+
execute_command: node skills/browser-analysis/helpers/analyze-browser-workspace.js
|
|
3649
|
+
b. Read the FCP diagnostic:
|
|
3650
|
+
read_file: /trace/rendering/fcp-diagnostic.json
|
|
3651
|
+
c. Read the visual progress:
|
|
3652
|
+
read_file: /trace/rendering/visual-progress.json
|
|
3653
|
+
2. From the FCP diagnostic, identify bottlenecks sorted by estimated delay.
|
|
3654
|
+
3. For each bottleneck, determine the root cause:
|
|
3655
|
+
- Render-blocking resources → read the source file and HTML
|
|
3656
|
+
- Long tasks before FCP → read the blocking script
|
|
3657
|
+
- Sequential chains → check CSS for @import and HTML for script ordering
|
|
3658
|
+
- Slow server response → note in finding, suggest CDN/caching
|
|
3659
|
+
4. Cross-reference visual progress with network waterfall to explain
|
|
3660
|
+
rendering order and identify missed optimization opportunities.
|
|
3661
|
+
5. Report each bottleneck and improvement opportunity as a separate finding.
|
|
3662
|
+
|
|
3663
|
+
### Source file path mapping
|
|
3664
|
+
|
|
3665
|
+
The FCP diagnostic data contains resource URLs (e.g. http://localhost:.../src/utils/foo.js).
|
|
3666
|
+
To read the actual source, map the URL's last path segment to its workspace path:
|
|
3667
|
+
- Scripts: URL ending in \`foo.js\` → workspace path \`/scripts/foo.js\`
|
|
3668
|
+
- Stylesheets: URL ending in \`bar.css\` → workspace path \`/styles/bar.css\`
|
|
3669
|
+
- HTML document: \`/html/index\`
|
|
3670
|
+
|
|
3671
|
+
Always use the WORKSPACE PATH (with leading /) in your finding's \`sourceFile\` field.
|
|
3672
|
+
|
|
3673
|
+
### CRITICAL: Report EACH issue as a SEPARATE finding
|
|
3674
|
+
|
|
3675
|
+
- Each render-blocking resource → separate finding
|
|
3676
|
+
- Each sequential resource chain → separate finding
|
|
3677
|
+
- Each long task before FCP → separate finding
|
|
3678
|
+
- Slow server response → separate finding
|
|
3679
|
+
- Poor visual progress pattern → separate finding
|
|
3680
|
+
|
|
3455
3681
|
${BROWSER_TOOL_CALL_STRATEGY}
|
|
3456
3682
|
${VERIFICATION_RULES}
|
|
3457
3683
|
${SEVERITY_RULES2}
|
|
@@ -3467,12 +3693,12 @@ var BROWSER_ORCHESTRATOR_PROMPT = `You are a performance analysis orchestrator.
|
|
|
3467
3693
|
|
|
3468
3694
|
## Instructions
|
|
3469
3695
|
|
|
3470
|
-
1. Read the user message — it contains
|
|
3471
|
-
2. In your FIRST response, call the \`task\` tool
|
|
3696
|
+
1. Read the user message — it contains task descriptions (4 or 5 depending on available data).
|
|
3697
|
+
2. In your FIRST response, call the \`task\` tool once per task description.
|
|
3472
3698
|
For each, set subagent_type and description EXACTLY as written in the
|
|
3473
3699
|
user message. Copy the FULL multi-line description verbatim, including
|
|
3474
3700
|
every file path listed.
|
|
3475
|
-
3. After all
|
|
3701
|
+
3. After all subagents return, respond with: "All subagents complete."
|
|
3476
3702
|
|
|
3477
3703
|
## CRITICAL rules
|
|
3478
3704
|
|
|
@@ -3536,6 +3762,22 @@ function buildAgentFileListSection(agentName, ctx) {
|
|
|
3536
3762
|
...files.styles.map((f) => ({ path: f, description: "(CSS source — check first)" }))
|
|
3537
3763
|
];
|
|
3538
3764
|
break;
|
|
3765
|
+
case "rendering-fcp":
|
|
3766
|
+
dataFiles = [
|
|
3767
|
+
{
|
|
3768
|
+
path: "/trace/rendering/fcp-diagnostic.json",
|
|
3769
|
+
description: "(FCP bottlenecks and correlation data — your PRIMARY data)"
|
|
3770
|
+
},
|
|
3771
|
+
{
|
|
3772
|
+
path: "/trace/rendering/visual-progress.json",
|
|
3773
|
+
description: "(visual change timeline, speed index, rendering phases)"
|
|
3774
|
+
},
|
|
3775
|
+
{
|
|
3776
|
+
path: "/trace/rendering/filmstrip.json",
|
|
3777
|
+
description: "(frame-by-frame rendering progress)"
|
|
3778
|
+
}
|
|
3779
|
+
];
|
|
3780
|
+
break;
|
|
3539
3781
|
}
|
|
3540
3782
|
const additionalSections = [];
|
|
3541
3783
|
if (agentName === "code-pattern") {
|
|
@@ -3553,6 +3795,14 @@ function buildAgentFileListSection(agentName, ctx) {
|
|
|
3553
3795
|
files: allSource
|
|
3554
3796
|
});
|
|
3555
3797
|
}
|
|
3798
|
+
} else if (agentName === "rendering-fcp") {
|
|
3799
|
+
const allSource = [...files.scripts, ...files.styles, ...files.html];
|
|
3800
|
+
if (allSource.length > 0) {
|
|
3801
|
+
additionalSections.push({
|
|
3802
|
+
title: "Available source files — read ONLY the ones implicated in FCP bottlenecks or visual delays",
|
|
3803
|
+
files: allSource
|
|
3804
|
+
});
|
|
3805
|
+
}
|
|
3556
3806
|
}
|
|
3557
3807
|
return buildFileListPromptSection({
|
|
3558
3808
|
dataFiles,
|
|
@@ -3566,13 +3816,28 @@ function buildBrowserUserMessage(ctx) {
|
|
|
3566
3816
|
const renderBlocking = traceResult.networkRequests.filter((r) => r.isRenderBlocking).length;
|
|
3567
3817
|
const totalTransfer = traceResult.networkRequests.reduce((s, r) => s + r.encodedSize, 0);
|
|
3568
3818
|
const hasRuntime = !!traceResult.runtimeTrace;
|
|
3819
|
+
const hasRendering = !!traceResult.renderingDiagnostic;
|
|
3569
3820
|
let runtimeInfo = "";
|
|
3570
3821
|
if (hasRuntime) {
|
|
3571
3822
|
const rt = traceResult.runtimeTrace;
|
|
3572
3823
|
runtimeInfo = `
|
|
3573
3824
|
Runtime trace: ${rt.blockingFunctions.length} blocking functions, ${rt.gcEvents.length} GC events (${Math.round(rt.gcEvents.reduce((s, e) => s + e.duration, 0))}ms total)`;
|
|
3574
3825
|
}
|
|
3575
|
-
|
|
3826
|
+
let renderingInfo = "";
|
|
3827
|
+
if (hasRendering) {
|
|
3828
|
+
const rd = traceResult.renderingDiagnostic;
|
|
3829
|
+
renderingInfo = `
|
|
3830
|
+
Rendering: Speed Index ${rd.speedIndex}ms, ${rd.fcpBottlenecks.length} FCP bottlenecks, ${rd.visualChanges.length} visual changes detected`;
|
|
3831
|
+
}
|
|
3832
|
+
const taskCount = hasRendering ? 5 : 4;
|
|
3833
|
+
let renderingTask = "";
|
|
3834
|
+
if (hasRendering) {
|
|
3835
|
+
renderingTask = `
|
|
3836
|
+
|
|
3837
|
+
TASK 5 — subagent_type: "rendering-fcp"
|
|
3838
|
+
description: "Analyze rendering speed and FCP behavior. Read /trace/rendering/fcp-diagnostic.json, /trace/rendering/visual-progress.json, and /trace/rendering/filmstrip.json FIRST. Identify FCP bottlenecks (render-blocking resources, long tasks before FCP, sequential resource chains, excessive layout). Cross-reference with network waterfall and source files. For each bottleneck, verify root cause and estimate delay. Report each bottleneck and rendering improvement as a separate finding. Do NOT suggest code fixes for minified/compiled JS."`;
|
|
3839
|
+
}
|
|
3840
|
+
return `Dispatch all ${taskCount} subagent tasks NOW in a single response.
|
|
3576
3841
|
Use these EXACT descriptions (copy them verbatim):
|
|
3577
3842
|
|
|
3578
3843
|
TASK 1 — subagent_type: "memory-heap"
|
|
@@ -3585,14 +3850,15 @@ TASK 3 — subagent_type: "runtime-blocking"
|
|
|
3585
3850
|
description: "Find runtime issues: main-thread blocking functions, event listener imbalances, GC pressure, layout thrashing, and unthrottled event handlers. Read /trace/runtime/ data files FIRST (do NOT read source files yet). Analyze blocking-functions.json for functions >50ms, event-listeners.json for add/remove imbalances. Then read ONLY the source files at the reported locations. Check for compound blockers (A calls blocking B — report BOTH). Report each distinct issue as a separate finding. Do NOT suggest code fixes for minified/compiled JS."
|
|
3586
3851
|
|
|
3587
3852
|
TASK 4 — subagent_type: "code-pattern"
|
|
3588
|
-
description: "Find frontend code anti-patterns: inline scripts, DOM manipulation in loops, missing event delegation, synchronous XHR, non-passive listeners, CSS issues, and missing image dimensions. Read HTML and CSS files FIRST. Check for inline <script> blocks, <img> without width/height, CSS @import. Then read ONLY the script files referenced by issues found. Report each pattern as a separate finding. Do NOT suggest code fixes for minified/compiled JS."
|
|
3853
|
+
description: "Find frontend code anti-patterns: inline scripts, DOM manipulation in loops, missing event delegation, synchronous XHR, non-passive listeners, CSS issues, and missing image dimensions. Read HTML and CSS files FIRST. Check for inline <script> blocks, <img> without width/height, CSS @import. Then read ONLY the script files referenced by issues found. Report each pattern as a separate finding. Do NOT suggest code fixes for minified/compiled JS."${renderingTask}
|
|
3589
3854
|
|
|
3590
3855
|
URL: ${url}
|
|
3591
3856
|
Page load: ${Math.round(m.loadComplete)}ms | FCP: ${Math.round(m.firstContentfulPaint)}ms | LCP: ${Math.round(m.largestContentfulPaint)}ms | TBT: ${Math.round(m.totalBlockingTime)}ms
|
|
3592
3857
|
Heap: ${formatBytes(heapSummary.metadata.totalSize)} total, ${heapSummary.metadata.nodeCount.toLocaleString()} nodes, ${heapSummary.detachedNodes.count} detached DOM nodes
|
|
3593
|
-
Network: ${reqCount} requests, ${formatBytes(totalTransfer)} transferred, ${renderBlocking} render-blocking${runtimeInfo}`;
|
|
3858
|
+
Network: ${reqCount} requests, ${formatBytes(totalTransfer)} transferred, ${renderBlocking} render-blocking${runtimeInfo}${renderingInfo}`;
|
|
3594
3859
|
}
|
|
3595
3860
|
function buildSubagents(ctx) {
|
|
3861
|
+
const hasRendering = !!ctx?.traceResult?.renderingDiagnostic;
|
|
3596
3862
|
const agentDefs = [
|
|
3597
3863
|
{
|
|
3598
3864
|
name: "memory-heap",
|
|
@@ -3613,7 +3879,14 @@ function buildSubagents(ctx) {
|
|
|
3613
3879
|
name: "code-pattern",
|
|
3614
3880
|
description: "Detects frontend code anti-patterns: inline scripts, DOM manipulation in loops, missing event delegation, non-passive listeners.",
|
|
3615
3881
|
prompt: CODE_PATTERN_PROMPT2
|
|
3616
|
-
}
|
|
3882
|
+
},
|
|
3883
|
+
...hasRendering ? [
|
|
3884
|
+
{
|
|
3885
|
+
name: "rendering-fcp",
|
|
3886
|
+
description: "Analyzes rendering speed and FCP behavior: identifies bottlenecks delaying first contentful paint, analyzes visual rendering order, and suggests rendering optimizations.",
|
|
3887
|
+
prompt: RENDERING_FCP_PROMPT
|
|
3888
|
+
}
|
|
3889
|
+
] : []
|
|
3617
3890
|
];
|
|
3618
3891
|
return agentDefs.map(({ name, description, prompt }) => {
|
|
3619
3892
|
const fileSection = ctx ? buildAgentFileListSection(name, ctx) : "";
|
|
@@ -3912,6 +4185,363 @@ function emptyRuntimeTrace() {
|
|
|
3912
4185
|
};
|
|
3913
4186
|
}
|
|
3914
4187
|
|
|
4188
|
+
// src/browser/screencast.ts
|
|
4189
|
+
var VISUAL_CHANGE_THRESHOLD = 0.1;
|
|
4190
|
+
var MAX_FRAMES = 200;
|
|
4191
|
+
async function startScreencast(cdpSession, navigationStartTs) {
|
|
4192
|
+
const frames = [];
|
|
4193
|
+
cdpSession.on("Page.screencastFrame", (params) => {
|
|
4194
|
+
if (frames.length >= MAX_FRAMES)
|
|
4195
|
+
return;
|
|
4196
|
+
const dataLength = Math.ceil((params.data?.length ?? 0) * 0.75);
|
|
4197
|
+
frames.push({
|
|
4198
|
+
timestamp: params.metadata?.timestamp ? params.metadata.timestamp * 1000 - navigationStartTs : Date.now() - navigationStartTs,
|
|
4199
|
+
data: params.data ?? "",
|
|
4200
|
+
sessionId: params.sessionId ?? 0,
|
|
4201
|
+
dataLength
|
|
4202
|
+
});
|
|
4203
|
+
try {
|
|
4204
|
+
cdpSession.send("Page.screencastFrameAck", {
|
|
4205
|
+
sessionId: params.sessionId
|
|
4206
|
+
});
|
|
4207
|
+
} catch {}
|
|
4208
|
+
});
|
|
4209
|
+
try {
|
|
4210
|
+
await cdpSession.send("Page.startScreencast", {
|
|
4211
|
+
format: "jpeg",
|
|
4212
|
+
quality: 40,
|
|
4213
|
+
maxWidth: 800,
|
|
4214
|
+
maxHeight: 600,
|
|
4215
|
+
everyNthFrame: 1
|
|
4216
|
+
});
|
|
4217
|
+
} catch {
|
|
4218
|
+
return { stop: async () => [] };
|
|
4219
|
+
}
|
|
4220
|
+
return {
|
|
4221
|
+
async stop() {
|
|
4222
|
+
try {
|
|
4223
|
+
await cdpSession.send("Page.stopScreencast");
|
|
4224
|
+
} catch {}
|
|
4225
|
+
return frames;
|
|
4226
|
+
}
|
|
4227
|
+
};
|
|
4228
|
+
}
|
|
4229
|
+
function detectVisualChanges(frames) {
|
|
4230
|
+
if (frames.length === 0)
|
|
4231
|
+
return [];
|
|
4232
|
+
const changes = [];
|
|
4233
|
+
const finalSize = frames[frames.length - 1].dataLength;
|
|
4234
|
+
if (finalSize === 0)
|
|
4235
|
+
return [];
|
|
4236
|
+
let prevSize = 0;
|
|
4237
|
+
for (let i = 0;i < frames.length; i++) {
|
|
4238
|
+
const frame = frames[i];
|
|
4239
|
+
const sizeDiff = Math.abs(frame.dataLength - prevSize);
|
|
4240
|
+
const relativeChange = prevSize > 0 ? sizeDiff / prevSize : frame.dataLength > 0 ? 1 : 0;
|
|
4241
|
+
if (relativeChange >= VISUAL_CHANGE_THRESHOLD || i === 0 && frame.dataLength > 0) {
|
|
4242
|
+
changes.push({
|
|
4243
|
+
timestamp: frame.timestamp,
|
|
4244
|
+
frameIndex: i,
|
|
4245
|
+
visualCompleteness: Math.min(100, Math.round(frame.dataLength / finalSize * 100)),
|
|
4246
|
+
changeMagnitude: Math.min(1, relativeChange)
|
|
4247
|
+
});
|
|
4248
|
+
}
|
|
4249
|
+
prevSize = frame.dataLength;
|
|
4250
|
+
}
|
|
4251
|
+
return changes;
|
|
4252
|
+
}
|
|
4253
|
+
function buildFilmstrip(frames, visualChanges) {
|
|
4254
|
+
const changeIndices = new Set(visualChanges.map((vc) => vc.frameIndex));
|
|
4255
|
+
return frames.map((frame, i) => ({
|
|
4256
|
+
index: i,
|
|
4257
|
+
timestamp: Math.round(frame.timestamp),
|
|
4258
|
+
dataLength: frame.dataLength,
|
|
4259
|
+
isVisualChange: changeIndices.has(i)
|
|
4260
|
+
}));
|
|
4261
|
+
}
|
|
4262
|
+
function approximateSpeedIndex(visualChanges) {
|
|
4263
|
+
if (visualChanges.length === 0)
|
|
4264
|
+
return 0;
|
|
4265
|
+
const sorted = [...visualChanges].sort((a, b) => a.timestamp - b.timestamp);
|
|
4266
|
+
const lastChange = sorted[sorted.length - 1];
|
|
4267
|
+
const totalTime = lastChange.timestamp;
|
|
4268
|
+
if (totalTime <= 0)
|
|
4269
|
+
return 0;
|
|
4270
|
+
let speedIndex = 0;
|
|
4271
|
+
let prevTimestamp = 0;
|
|
4272
|
+
let prevCompleteness = 0;
|
|
4273
|
+
for (const change of sorted) {
|
|
4274
|
+
const duration = change.timestamp - prevTimestamp;
|
|
4275
|
+
speedIndex += duration * (1 - prevCompleteness / 100);
|
|
4276
|
+
prevTimestamp = change.timestamp;
|
|
4277
|
+
prevCompleteness = change.visualCompleteness;
|
|
4278
|
+
}
|
|
4279
|
+
if (prevCompleteness < 100) {
|
|
4280
|
+
speedIndex += (totalTime - prevTimestamp) * (1 - prevCompleteness / 100);
|
|
4281
|
+
}
|
|
4282
|
+
return Math.round(speedIndex);
|
|
4283
|
+
}
|
|
4284
|
+
|
|
4285
|
+
// src/browser/fcp-analysis.ts
|
|
4286
|
+
function buildRenderingDiagnostic(traceResult, frames) {
|
|
4287
|
+
const { metrics, runtimeTrace, rawTraceEvents } = traceResult;
|
|
4288
|
+
const fcpTs = metrics.firstContentfulPaint;
|
|
4289
|
+
const visualChanges = detectVisualChanges(frames);
|
|
4290
|
+
const filmstrip = buildFilmstrip(frames, visualChanges);
|
|
4291
|
+
const speedIndex = approximateSpeedIndex(visualChanges);
|
|
4292
|
+
const fcpCorrelation = buildFCPCorrelation(traceResult, frames, fcpTs);
|
|
4293
|
+
const renderingPhases = buildRenderingPhases(metrics, rawTraceEvents ?? [], runtimeTrace?.mainThreadId ?? 0);
|
|
4294
|
+
const fcpBottlenecks = identifyFCPBottlenecks(fcpCorrelation, traceResult);
|
|
4295
|
+
return {
|
|
4296
|
+
filmstrip,
|
|
4297
|
+
visualChanges,
|
|
4298
|
+
fcpCorrelation,
|
|
4299
|
+
renderingPhases,
|
|
4300
|
+
speedIndex,
|
|
4301
|
+
fcpBottlenecks
|
|
4302
|
+
};
|
|
4303
|
+
}
|
|
4304
|
+
function buildFCPCorrelation(traceResult, frames, fcpTs) {
|
|
4305
|
+
const { networkRequests, runtimeTrace } = traceResult;
|
|
4306
|
+
let nearestFrameIndex = 0;
|
|
4307
|
+
let minDelta = Infinity;
|
|
4308
|
+
for (let i = 0;i < frames.length; i++) {
|
|
4309
|
+
const delta = Math.abs(frames[i].timestamp - fcpTs);
|
|
4310
|
+
if (delta < minDelta) {
|
|
4311
|
+
minDelta = delta;
|
|
4312
|
+
nearestFrameIndex = i;
|
|
4313
|
+
}
|
|
4314
|
+
}
|
|
4315
|
+
const resourcesLoadedBeforeFCP = networkRequests.filter((r) => r.endTime > 0 && r.endTime <= fcpTs).sort((a, b) => a.endTime - b.endTime).map((r) => ({
|
|
4316
|
+
url: r.url,
|
|
4317
|
+
resourceType: r.resourceType,
|
|
4318
|
+
duration: Math.round(r.duration),
|
|
4319
|
+
isRenderBlocking: r.isRenderBlocking,
|
|
4320
|
+
endTime: Math.round(r.endTime)
|
|
4321
|
+
}));
|
|
4322
|
+
const resourcesLoadingAtFCP = networkRequests.filter((r) => r.startTime <= fcpTs && (r.endTime === 0 || r.endTime > fcpTs)).map((r) => ({
|
|
4323
|
+
url: r.url,
|
|
4324
|
+
resourceType: r.resourceType,
|
|
4325
|
+
startTime: Math.round(r.startTime)
|
|
4326
|
+
}));
|
|
4327
|
+
const renderBlockingChain = networkRequests.filter((r) => r.isRenderBlocking && r.endTime > 0 && r.endTime <= fcpTs).sort((a, b) => a.startTime - b.startTime).map((r) => ({
|
|
4328
|
+
url: r.url,
|
|
4329
|
+
resourceType: r.resourceType,
|
|
4330
|
+
duration: Math.round(r.duration),
|
|
4331
|
+
size: r.decodedSize
|
|
4332
|
+
}));
|
|
4333
|
+
const mainThreadBlockersBeforeFCP = (runtimeTrace?.blockingFunctions ?? []).filter((bf) => bf.startTime + bf.duration <= fcpTs).map((bf) => ({
|
|
4334
|
+
functionName: bf.functionName,
|
|
4335
|
+
scriptUrl: bf.scriptUrl,
|
|
4336
|
+
duration: Math.round(bf.duration),
|
|
4337
|
+
startTime: Math.round(bf.startTime)
|
|
4338
|
+
}));
|
|
4339
|
+
const totalBlockingTimeBeforeFCP = mainThreadBlockersBeforeFCP.reduce((sum, bf) => sum + Math.max(0, bf.duration - 50), 0);
|
|
4340
|
+
const layoutTimeBeforeFCP = computeLayoutTimeBeforeFCP(traceResult, fcpTs);
|
|
4341
|
+
return {
|
|
4342
|
+
fcpTimestamp: Math.round(fcpTs),
|
|
4343
|
+
nearestFrameIndex,
|
|
4344
|
+
resourcesLoadedBeforeFCP,
|
|
4345
|
+
resourcesLoadingAtFCP,
|
|
4346
|
+
renderBlockingChain,
|
|
4347
|
+
mainThreadBlockersBeforeFCP,
|
|
4348
|
+
totalBlockingTimeBeforeFCP: Math.round(totalBlockingTimeBeforeFCP),
|
|
4349
|
+
layoutTimeBeforeFCP: Math.round(layoutTimeBeforeFCP)
|
|
4350
|
+
};
|
|
4351
|
+
}
|
|
4352
|
+
function computeLayoutTimeBeforeFCP(traceResult, fcpTs) {
|
|
4353
|
+
const { rawTraceEvents, runtimeTrace } = traceResult;
|
|
4354
|
+
if (!rawTraceEvents || rawTraceEvents.length === 0)
|
|
4355
|
+
return 0;
|
|
4356
|
+
const mainTid = runtimeTrace?.mainThreadId ?? 0;
|
|
4357
|
+
const navEvent = rawTraceEvents.find((e) => e.name === "navigationStart" || e.name === "NavigationStart");
|
|
4358
|
+
const navTs = navEvent?.ts ?? rawTraceEvents[0]?.ts ?? 0;
|
|
4359
|
+
const fcpUs = fcpTs * 1000 + navTs;
|
|
4360
|
+
const layoutEvents = new Set(["Layout", "UpdateLayoutTree", "RecalculateStyles"]);
|
|
4361
|
+
let layoutTime = 0;
|
|
4362
|
+
for (const e of rawTraceEvents) {
|
|
4363
|
+
if (e.tid !== mainTid || e.ph !== "X" || !e.dur)
|
|
4364
|
+
continue;
|
|
4365
|
+
if (!layoutEvents.has(e.name))
|
|
4366
|
+
continue;
|
|
4367
|
+
if (e.ts + e.dur <= fcpUs) {
|
|
4368
|
+
layoutTime += e.dur / 1000;
|
|
4369
|
+
}
|
|
4370
|
+
}
|
|
4371
|
+
return layoutTime;
|
|
4372
|
+
}
|
|
4373
|
+
function buildRenderingPhases(metrics, rawTraceEvents, mainThreadId) {
|
|
4374
|
+
const phases = [];
|
|
4375
|
+
const fp = metrics.firstPaint;
|
|
4376
|
+
const fcp = metrics.firstContentfulPaint;
|
|
4377
|
+
const dcl = metrics.domContentLoaded;
|
|
4378
|
+
const load = metrics.loadComplete;
|
|
4379
|
+
if (fp > 0) {
|
|
4380
|
+
phases.push({
|
|
4381
|
+
name: "Server & Network",
|
|
4382
|
+
startTime: 0,
|
|
4383
|
+
endTime: Math.round(fp),
|
|
4384
|
+
duration: Math.round(fp),
|
|
4385
|
+
description: "Time from navigation start to first paint — includes DNS, TCP, TLS, server response, and HTML parsing."
|
|
4386
|
+
});
|
|
4387
|
+
}
|
|
4388
|
+
if (fcp > fp && fp > 0) {
|
|
4389
|
+
phases.push({
|
|
4390
|
+
name: "First Paint to FCP",
|
|
4391
|
+
startTime: Math.round(fp),
|
|
4392
|
+
endTime: Math.round(fcp),
|
|
4393
|
+
duration: Math.round(fcp - fp),
|
|
4394
|
+
description: "Time between first paint (background/border) and first contentful paint (text/image). Render-blocking resources and large DOM delay this phase."
|
|
4395
|
+
});
|
|
4396
|
+
} else if (fcp > 0) {
|
|
4397
|
+
phases.push({
|
|
4398
|
+
name: "Navigation to FCP",
|
|
4399
|
+
startTime: 0,
|
|
4400
|
+
endTime: Math.round(fcp),
|
|
4401
|
+
duration: Math.round(fcp),
|
|
4402
|
+
description: "Time from navigation start to first contentful paint. Includes all network, parsing, and rendering work."
|
|
4403
|
+
});
|
|
4404
|
+
}
|
|
4405
|
+
if (dcl > fcp && fcp > 0) {
|
|
4406
|
+
phases.push({
|
|
4407
|
+
name: "FCP to DOMContentLoaded",
|
|
4408
|
+
startTime: Math.round(fcp),
|
|
4409
|
+
endTime: Math.round(dcl),
|
|
4410
|
+
duration: Math.round(dcl - fcp),
|
|
4411
|
+
description: "Time between FCP and DOMContentLoaded. Deferred scripts execute during this phase."
|
|
4412
|
+
});
|
|
4413
|
+
}
|
|
4414
|
+
if (load > dcl && dcl > 0) {
|
|
4415
|
+
phases.push({
|
|
4416
|
+
name: "DOMContentLoaded to Load",
|
|
4417
|
+
startTime: Math.round(dcl),
|
|
4418
|
+
endTime: Math.round(load),
|
|
4419
|
+
duration: Math.round(load - dcl),
|
|
4420
|
+
description: "Time between DOMContentLoaded and load event. Images, fonts, and async resources finish loading."
|
|
4421
|
+
});
|
|
4422
|
+
}
|
|
4423
|
+
if (rawTraceEvents.length > 0) {
|
|
4424
|
+
annotatePhaseActivity(phases, rawTraceEvents, mainThreadId);
|
|
4425
|
+
}
|
|
4426
|
+
return phases;
|
|
4427
|
+
}
|
|
4428
|
+
function annotatePhaseActivity(phases, rawTraceEvents, mainThreadId) {
|
|
4429
|
+
const navEvent = rawTraceEvents.find((e) => e.name === "navigationStart" || e.name === "NavigationStart");
|
|
4430
|
+
const navTs = navEvent?.ts ?? rawTraceEvents[0]?.ts ?? 0;
|
|
4431
|
+
const scriptingEvents = new Set([
|
|
4432
|
+
"FunctionCall",
|
|
4433
|
+
"EvaluateScript",
|
|
4434
|
+
"TimerFire",
|
|
4435
|
+
"RequestAnimationFrame"
|
|
4436
|
+
]);
|
|
4437
|
+
const layoutEvts = new Set(["Layout", "UpdateLayoutTree", "RecalculateStyles"]);
|
|
4438
|
+
for (const phase of phases) {
|
|
4439
|
+
const startUs = phase.startTime * 1000 + navTs;
|
|
4440
|
+
const endUs = phase.endTime * 1000 + navTs;
|
|
4441
|
+
let scripting = 0;
|
|
4442
|
+
let layout = 0;
|
|
4443
|
+
let painting = 0;
|
|
4444
|
+
for (const e of rawTraceEvents) {
|
|
4445
|
+
if (e.tid !== mainThreadId || e.ph !== "X" || !e.dur)
|
|
4446
|
+
continue;
|
|
4447
|
+
if (e.ts >= endUs || e.ts + e.dur <= startUs)
|
|
4448
|
+
continue;
|
|
4449
|
+
const overlapStart = Math.max(e.ts, startUs);
|
|
4450
|
+
const overlapEnd = Math.min(e.ts + e.dur, endUs);
|
|
4451
|
+
const overlapMs = (overlapEnd - overlapStart) / 1000;
|
|
4452
|
+
if (scriptingEvents.has(e.name))
|
|
4453
|
+
scripting += overlapMs;
|
|
4454
|
+
else if (layoutEvts.has(e.name))
|
|
4455
|
+
layout += overlapMs;
|
|
4456
|
+
else if (e.name === "Paint" || e.name === "CompositeLayers")
|
|
4457
|
+
painting += overlapMs;
|
|
4458
|
+
}
|
|
4459
|
+
if (scripting > 0 || layout > 0 || painting > 0) {
|
|
4460
|
+
const parts = [];
|
|
4461
|
+
if (scripting > 0)
|
|
4462
|
+
parts.push(`${Math.round(scripting)}ms scripting`);
|
|
4463
|
+
if (layout > 0)
|
|
4464
|
+
parts.push(`${Math.round(layout)}ms layout`);
|
|
4465
|
+
if (painting > 0)
|
|
4466
|
+
parts.push(`${Math.round(painting)}ms painting`);
|
|
4467
|
+
phase.description += ` Main-thread breakdown: ${parts.join(", ")}.`;
|
|
4468
|
+
}
|
|
4469
|
+
}
|
|
4470
|
+
}
|
|
4471
|
+
function identifyFCPBottlenecks(correlation, traceResult) {
|
|
4472
|
+
const bottlenecks = [];
|
|
4473
|
+
for (const rb of correlation.renderBlockingChain) {
|
|
4474
|
+
bottlenecks.push({
|
|
4475
|
+
type: "render-blocking-resource",
|
|
4476
|
+
description: `Render-blocking ${rb.resourceType.toLowerCase()} (${formatSize(rb.size)}) delayed FCP by ~${rb.duration}ms. Consider async/defer loading or inlining critical content.`,
|
|
4477
|
+
estimatedDelayMs: rb.duration,
|
|
4478
|
+
source: rb.url
|
|
4479
|
+
});
|
|
4480
|
+
}
|
|
4481
|
+
for (const bf of correlation.mainThreadBlockersBeforeFCP) {
|
|
4482
|
+
if (bf.duration >= 50) {
|
|
4483
|
+
bottlenecks.push({
|
|
4484
|
+
type: "long-task-before-fcp",
|
|
4485
|
+
description: `Long task "${bf.functionName}" blocked the main thread for ${bf.duration}ms before FCP. This prevents the browser from rendering content.`,
|
|
4486
|
+
estimatedDelayMs: bf.duration - 50,
|
|
4487
|
+
source: bf.scriptUrl || bf.functionName
|
|
4488
|
+
});
|
|
4489
|
+
}
|
|
4490
|
+
}
|
|
4491
|
+
const docRequest = traceResult.networkRequests.find((r) => r.resourceType === "Document");
|
|
4492
|
+
if (docRequest && docRequest.duration > 600) {
|
|
4493
|
+
bottlenecks.push({
|
|
4494
|
+
type: "slow-server-response",
|
|
4495
|
+
description: `Server took ${Math.round(docRequest.duration)}ms to respond with the HTML document. Consider server-side caching, CDN, or SSR optimization.`,
|
|
4496
|
+
estimatedDelayMs: Math.round(docRequest.duration - 200),
|
|
4497
|
+
source: docRequest.url
|
|
4498
|
+
});
|
|
4499
|
+
}
|
|
4500
|
+
const renderBlockingRequests = traceResult.networkRequests.filter((r) => r.isRenderBlocking).sort((a, b) => a.startTime - b.startTime);
|
|
4501
|
+
for (let i = 1;i < renderBlockingRequests.length; i++) {
|
|
4502
|
+
const prev = renderBlockingRequests[i - 1];
|
|
4503
|
+
const curr = renderBlockingRequests[i];
|
|
4504
|
+
const gap = curr.startTime - prev.endTime;
|
|
4505
|
+
if (gap < 50 && prev.endTime > 0 && curr.startTime > prev.endTime - 10) {
|
|
4506
|
+
const chainDelay = curr.endTime - prev.startTime;
|
|
4507
|
+
const parallelTime = Math.max(prev.duration, curr.duration);
|
|
4508
|
+
const savings = chainDelay - parallelTime;
|
|
4509
|
+
if (savings > 50) {
|
|
4510
|
+
bottlenecks.push({
|
|
4511
|
+
type: "sequential-resource-chain",
|
|
4512
|
+
description: `Sequential render-blocking chain: "${basename(prev.url)}" → "${basename(curr.url)}" adds ~${Math.round(savings)}ms. Preloading or parallelizing would reduce FCP.`,
|
|
4513
|
+
estimatedDelayMs: Math.round(savings),
|
|
4514
|
+
source: curr.url
|
|
4515
|
+
});
|
|
4516
|
+
}
|
|
4517
|
+
}
|
|
4518
|
+
}
|
|
4519
|
+
if (correlation.layoutTimeBeforeFCP > 100) {
|
|
4520
|
+
bottlenecks.push({
|
|
4521
|
+
type: "excessive-layout",
|
|
4522
|
+
description: `${Math.round(correlation.layoutTimeBeforeFCP)}ms spent in layout/style recalculation before FCP. Reduce CSS complexity or DOM size for the initial render.`,
|
|
4523
|
+
estimatedDelayMs: Math.round(correlation.layoutTimeBeforeFCP * 0.5),
|
|
4524
|
+
source: "layout/style-recalculation"
|
|
4525
|
+
});
|
|
4526
|
+
}
|
|
4527
|
+
bottlenecks.sort((a, b) => b.estimatedDelayMs - a.estimatedDelayMs);
|
|
4528
|
+
return bottlenecks;
|
|
4529
|
+
}
|
|
4530
|
+
function formatSize(bytes) {
|
|
4531
|
+
if (bytes < 1024)
|
|
4532
|
+
return `${bytes}B`;
|
|
4533
|
+
if (bytes < 1024 * 1024)
|
|
4534
|
+
return `${(bytes / 1024).toFixed(1)}KB`;
|
|
4535
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
4536
|
+
}
|
|
4537
|
+
function basename(url) {
|
|
4538
|
+
try {
|
|
4539
|
+
return new URL(url).pathname.split("/").pop() || url;
|
|
4540
|
+
} catch {
|
|
4541
|
+
return url;
|
|
4542
|
+
}
|
|
4543
|
+
}
|
|
4544
|
+
|
|
3915
4545
|
// src/browser/trace.ts
|
|
3916
4546
|
var TEXT_RESOURCE_TYPES = new Set(["Script", "Stylesheet", "Document", "XHR", "Fetch"]);
|
|
3917
4547
|
var MAX_BODY_SIZE = 2 * 1024 * 1024;
|
|
@@ -3927,6 +4557,7 @@ var TRACING_COMPLETE_TIMEOUT = 1e4;
|
|
|
3927
4557
|
async function tracePageLoad(cdpSession, _options = {}) {
|
|
3928
4558
|
const requests = new Map;
|
|
3929
4559
|
const finishedIds = new Set;
|
|
4560
|
+
const enableScreencast = _options.screencast !== false;
|
|
3930
4561
|
const traceEvents = [];
|
|
3931
4562
|
let tracingStarted = false;
|
|
3932
4563
|
try {
|
|
@@ -3944,6 +4575,13 @@ async function tracePageLoad(cdpSession, _options = {}) {
|
|
|
3944
4575
|
});
|
|
3945
4576
|
tracingStarted = true;
|
|
3946
4577
|
} catch {}
|
|
4578
|
+
let screencastHandle;
|
|
4579
|
+
const navigationStartMs = Date.now();
|
|
4580
|
+
if (enableScreencast) {
|
|
4581
|
+
try {
|
|
4582
|
+
screencastHandle = await startScreencast(cdpSession, navigationStartMs);
|
|
4583
|
+
} catch {}
|
|
4584
|
+
}
|
|
3947
4585
|
await cdpSession.send("Network.enable");
|
|
3948
4586
|
cdpSession.on("Network.requestWillBeSent", (params) => {
|
|
3949
4587
|
requests.set(params.requestId, {
|
|
@@ -4069,6 +4707,12 @@ async function tracePageLoad(cdpSession, _options = {}) {
|
|
|
4069
4707
|
} catch {}
|
|
4070
4708
|
const fp = paintEntries.find((e) => e.name === "first-paint");
|
|
4071
4709
|
const fcp = paintEntries.find((e) => e.name === "first-contentful-paint");
|
|
4710
|
+
let screencastFrames = [];
|
|
4711
|
+
if (screencastHandle) {
|
|
4712
|
+
try {
|
|
4713
|
+
screencastFrames = await screencastHandle.stop();
|
|
4714
|
+
} catch {}
|
|
4715
|
+
}
|
|
4072
4716
|
await cdpSession.send("Network.disable");
|
|
4073
4717
|
const metrics = {
|
|
4074
4718
|
navigationStart: perfMetrics.navigationStart ?? 0,
|
|
@@ -4080,12 +4724,19 @@ async function tracePageLoad(cdpSession, _options = {}) {
|
|
|
4080
4724
|
totalBlockingTime: longTasks.reduce((sum, t) => sum + Math.max(0, t.duration - 50), 0),
|
|
4081
4725
|
longTasks
|
|
4082
4726
|
};
|
|
4083
|
-
|
|
4727
|
+
const traceResult = {
|
|
4084
4728
|
networkRequests,
|
|
4085
4729
|
metrics,
|
|
4086
4730
|
runtimeTrace,
|
|
4087
4731
|
rawTraceEvents: tracingStarted ? traceEvents : undefined
|
|
4088
4732
|
};
|
|
4733
|
+
if (screencastFrames.length > 0) {
|
|
4734
|
+
try {
|
|
4735
|
+
traceResult.renderingDiagnostic = buildRenderingDiagnostic(traceResult, screencastFrames);
|
|
4736
|
+
} catch {}
|
|
4737
|
+
traceResult.screencastFrames = screencastFrames;
|
|
4738
|
+
}
|
|
4739
|
+
return traceResult;
|
|
4089
4740
|
}
|
|
4090
4741
|
};
|
|
4091
4742
|
}
|
|
@@ -4463,6 +5114,27 @@ async function createWorkspace(options) {
|
|
|
4463
5114
|
files["/trace/runtime/event-listeners.json"] = JSON.stringify(rt.eventListeners.filter((l) => l.addCount > 0), null, 2);
|
|
4464
5115
|
files["/trace/runtime/frame-breakdown.json"] = JSON.stringify(rt.frameBreakdown, null, 2);
|
|
4465
5116
|
}
|
|
5117
|
+
if (traceResult.renderingDiagnostic) {
|
|
5118
|
+
const rd = traceResult.renderingDiagnostic;
|
|
5119
|
+
files["/trace/rendering/fcp-diagnostic.json"] = JSON.stringify({
|
|
5120
|
+
fcpTimestamp: rd.fcpCorrelation.fcpTimestamp,
|
|
5121
|
+
speedIndex: rd.speedIndex,
|
|
5122
|
+
bottleneckCount: rd.fcpBottlenecks.length,
|
|
5123
|
+
totalEstimatedDelay: rd.fcpBottlenecks.reduce((s, b) => s + b.estimatedDelayMs, 0),
|
|
5124
|
+
fcpCorrelation: rd.fcpCorrelation,
|
|
5125
|
+
fcpBottlenecks: rd.fcpBottlenecks
|
|
5126
|
+
}, null, 2);
|
|
5127
|
+
files["/trace/rendering/filmstrip.json"] = JSON.stringify({
|
|
5128
|
+
frameCount: rd.filmstrip.length,
|
|
5129
|
+
visualChangeCount: rd.visualChanges.length,
|
|
5130
|
+
frames: rd.filmstrip
|
|
5131
|
+
}, null, 2);
|
|
5132
|
+
files["/trace/rendering/visual-progress.json"] = JSON.stringify({
|
|
5133
|
+
speedIndex: rd.speedIndex,
|
|
5134
|
+
visualChanges: rd.visualChanges,
|
|
5135
|
+
renderingPhases: rd.renderingPhases
|
|
5136
|
+
}, null, 2);
|
|
5137
|
+
}
|
|
4466
5138
|
if (traceResult.rawTraceEvents && traceResult.rawTraceEvents.length > 0) {
|
|
4467
5139
|
const rawJson = JSON.stringify(traceResult.rawTraceEvents);
|
|
4468
5140
|
if (rawJson.length < 5 * 1024 * 1024) {
|
|
@@ -4616,10 +5288,12 @@ async function main() {
|
|
|
4616
5288
|
const longTaskCount = captureResult.trace.metrics.longTasks.length;
|
|
4617
5289
|
const runtimeTraceInfo = captureResult.trace.runtimeTrace ? `
|
|
4618
5290
|
Runtime trace: ${captureResult.trace.runtimeTrace.totalEvents.toLocaleString()} events captured` : "";
|
|
5291
|
+
const renderingInfo = captureResult.trace.renderingDiagnostic ? `
|
|
5292
|
+
Rendering: FCP ${Math.round(captureResult.trace.metrics.firstContentfulPaint)}ms, Speed Index ${captureResult.trace.renderingDiagnostic.speedIndex}ms, ${captureResult.trace.renderingDiagnostic.fcpBottlenecks.length} bottlenecks, ${captureResult.trace.renderingDiagnostic.filmstrip.length} frames` : "";
|
|
4619
5293
|
captureSpinner.succeed(`Page loaded in ${(captureResult.trace.metrics.loadComplete / 1000).toFixed(1)}s
|
|
4620
5294
|
` + ` Heap snapshot: ${heapSizeMB} MB
|
|
4621
5295
|
` + ` Network requests: ${reqCount} captured
|
|
4622
|
-
` + ` Long tasks: ${longTaskCount} detected` + runtimeTraceInfo);
|
|
5296
|
+
` + ` Long tasks: ${longTaskCount} detected` + runtimeTraceInfo + renderingInfo);
|
|
4623
5297
|
} catch (err) {
|
|
4624
5298
|
captureSpinner.fail("Failed to capture page data");
|
|
4625
5299
|
throw new Error(`Failed to capture data from ${url}.
|
|
@@ -4641,7 +5315,9 @@ async function main() {
|
|
|
4641
5315
|
const totalSize = captureResult.trace.networkRequests.filter((r) => r.responseBody).reduce((sum, r) => sum + (r.responseBody?.length ?? 0), 0);
|
|
4642
5316
|
const runtimeWorkspaceInfo = captureResult.trace.runtimeTrace ? `
|
|
4643
5317
|
Runtime trace: summaries + raw events` : "";
|
|
4644
|
-
|
|
5318
|
+
const renderingWorkspaceInfo = captureResult.trace.renderingDiagnostic ? `
|
|
5319
|
+
Rendering diagnostics: FCP correlation + visual progress + filmstrip` : "";
|
|
5320
|
+
workspaceSpinner.succeed(`${storedCount} assets stored in workspace (${formatBytes(totalSize)} total)` + runtimeWorkspaceInfo + renderingWorkspaceInfo);
|
|
4645
5321
|
} catch (err) {
|
|
4646
5322
|
workspaceSpinner.fail("Failed to build workspace");
|
|
4647
5323
|
throw new Error(`Failed to create workspace.
|
|
@@ -4683,7 +5359,12 @@ async function main() {
|
|
|
4683
5359
|
heapNodeCount: heapSummary.metadata.nodeCount,
|
|
4684
5360
|
detachedDomNodes: heapSummary.detachedNodes.count,
|
|
4685
5361
|
networkRequests: captureResult.trace.networkRequests.length,
|
|
4686
|
-
totalTransferSize: captureResult.trace.networkRequests.reduce((s, r) => s + r.encodedSize, 0)
|
|
5362
|
+
totalTransferSize: captureResult.trace.networkRequests.reduce((s, r) => s + r.encodedSize, 0),
|
|
5363
|
+
...captureResult.trace.renderingDiagnostic ? {
|
|
5364
|
+
speedIndex: captureResult.trace.renderingDiagnostic.speedIndex,
|
|
5365
|
+
fcpBottlenecks: captureResult.trace.renderingDiagnostic.fcpBottlenecks.length,
|
|
5366
|
+
renderingPhases: captureResult.trace.renderingDiagnostic.renderingPhases.length
|
|
5367
|
+
} : {}
|
|
4687
5368
|
}
|
|
4688
5369
|
};
|
|
4689
5370
|
const { writeFileSync: writeFileSync2 } = await import("node:fs");
|