@decocms/start 0.19.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/.cursor/skills/deco-api-call-dedup/SKILL.md +443 -0
- package/.cursor/skills/deco-apps-architecture/SKILL.md +255 -0
- package/.cursor/skills/deco-apps-architecture/app-pattern.md +288 -0
- package/.cursor/skills/deco-apps-architecture/commerce-types.md +239 -0
- package/.cursor/skills/deco-apps-architecture/new-app-guide.md +268 -0
- package/.cursor/skills/deco-apps-architecture/scripts-codegen.md +148 -0
- package/.cursor/skills/deco-apps-architecture/shared-utils.md +181 -0
- package/.cursor/skills/deco-apps-architecture/vtex-deep-structure.md +253 -0
- package/.cursor/skills/deco-apps-architecture/website-app.md +169 -0
- package/.cursor/skills/deco-apps-vtex-porting/SKILL.md +189 -0
- package/.cursor/skills/deco-apps-vtex-porting/adaptation-patterns.md +335 -0
- package/.cursor/skills/deco-apps-vtex-porting/commerce-porting.md +155 -0
- package/.cursor/skills/deco-apps-vtex-porting/cookie-auth-patterns.md +148 -0
- package/.cursor/skills/deco-apps-vtex-porting/structure-map.md +234 -0
- package/.cursor/skills/deco-apps-vtex-porting/transform-mapping.md +99 -0
- package/.cursor/skills/deco-apps-vtex-porting/website-porting.md +194 -0
- package/.cursor/skills/deco-apps-vtex-review/SKILL.md +234 -0
- package/.cursor/skills/deco-async-rendering-architecture/SKILL.md +270 -0
- package/.cursor/skills/deco-async-rendering-site-guide/SKILL.md +417 -0
- package/.cursor/skills/deco-cms-layout-caching/SKILL.md +293 -0
- package/.cursor/skills/deco-cms-route-config/SKILL.md +388 -0
- package/.cursor/skills/deco-core-architecture/SKILL.md +185 -0
- package/.cursor/skills/deco-core-architecture/blocks.md +196 -0
- package/.cursor/skills/deco-core-architecture/deco-vs-deco-start.md +191 -0
- package/.cursor/skills/deco-core-architecture/engine.md +220 -0
- package/.cursor/skills/deco-core-architecture/hooks-components.md +157 -0
- package/.cursor/skills/deco-core-architecture/plugins-clients.md +136 -0
- package/.cursor/skills/deco-core-architecture/runtime.md +116 -0
- package/.cursor/skills/deco-core-architecture/site-usage.md +165 -0
- package/.cursor/skills/deco-e2e-testing/SKILL.md +372 -0
- package/.cursor/skills/deco-e2e-testing/discovery.md +337 -0
- package/.cursor/skills/deco-e2e-testing/scripts/scaffold.sh +81 -0
- package/.cursor/skills/deco-e2e-testing/selectors.md +175 -0
- package/.cursor/skills/deco-e2e-testing/templates/package.json +18 -0
- package/.cursor/skills/deco-e2e-testing/templates/playwright.config.ts +65 -0
- package/.cursor/skills/deco-e2e-testing/templates/scripts/baseline.ts +279 -0
- package/.cursor/skills/deco-e2e-testing/templates/scripts/run-e2e.ts +194 -0
- package/.cursor/skills/deco-e2e-testing/templates/specs/ecommerce-flow.spec.ts +612 -0
- package/.cursor/skills/deco-e2e-testing/templates/tsconfig.json +12 -0
- package/.cursor/skills/deco-e2e-testing/templates/utils/metrics-collector.ts +918 -0
- package/.cursor/skills/deco-e2e-testing/troubleshooting.md +602 -0
- package/.cursor/skills/deco-edge-caching/SKILL.md +316 -0
- package/.cursor/skills/deco-full-analysis/SKILL.md +898 -0
- package/.cursor/skills/deco-full-analysis/checklists/asset-optimization.md +251 -0
- package/.cursor/skills/deco-full-analysis/checklists/bug-fix.md +189 -0
- package/.cursor/skills/deco-full-analysis/checklists/cache-strategy.md +144 -0
- package/.cursor/skills/deco-full-analysis/checklists/dependency-update.md +150 -0
- package/.cursor/skills/deco-full-analysis/checklists/hydration-fix.md +191 -0
- package/.cursor/skills/deco-full-analysis/checklists/image-optimization.md +180 -0
- package/.cursor/skills/deco-full-analysis/checklists/loader-optimization.md +165 -0
- package/.cursor/skills/deco-full-analysis/checklists/seo-fix.md +183 -0
- package/.cursor/skills/deco-full-analysis/checklists/site-cleanup.md +281 -0
- package/.cursor/skills/deco-full-analysis/discovery.md +548 -0
- package/.cursor/skills/deco-incident-debugging/SKILL.md +378 -0
- package/.cursor/skills/deco-incident-debugging/headless-mode.md +510 -0
- package/.cursor/skills/deco-incident-debugging/learnings-index.md +227 -0
- package/.cursor/skills/deco-incident-debugging/triage-workflow.md +312 -0
- package/.cursor/skills/deco-islands-migration/SKILL.md +251 -0
- package/.cursor/skills/deco-loader-n-plus-1-detector/SKILL.md +275 -0
- package/.cursor/skills/deco-performance-audit/SKILL.md +530 -0
- package/.cursor/skills/deco-performance-audit/tools-reference.md +428 -0
- package/.cursor/skills/deco-performance-audit/workflow.md +457 -0
- package/.cursor/skills/deco-server-functions-invoke/SKILL.md +92 -0
- package/.cursor/skills/deco-server-functions-invoke/architecture.md +166 -0
- package/.cursor/skills/deco-server-functions-invoke/generator.md +122 -0
- package/.cursor/skills/deco-server-functions-invoke/problem.md +98 -0
- package/.cursor/skills/deco-server-functions-invoke/troubleshooting.md +110 -0
- package/.cursor/skills/deco-site-deployment/SKILL.md +396 -0
- package/.cursor/skills/deco-site-memory-debugging/SKILL.md +121 -0
- package/.cursor/skills/deco-site-memory-debugging/cdp-connection.md +222 -0
- package/.cursor/skills/deco-site-memory-debugging/memory-analysis.md +362 -0
- package/.cursor/skills/deco-site-patterns/SKILL.md +124 -0
- package/.cursor/skills/deco-site-patterns/app-composition.md +337 -0
- package/.cursor/skills/deco-site-patterns/client-patterns.md +341 -0
- package/.cursor/skills/deco-site-patterns/cms-wiring.md +230 -0
- package/.cursor/skills/deco-site-patterns/section-patterns.md +340 -0
- package/.cursor/skills/deco-site-scaling-tuning/SKILL.md +240 -0
- package/.cursor/skills/deco-site-scaling-tuning/analysis-scripts.md +267 -0
- package/.cursor/skills/deco-start-architecture/SKILL.md +218 -0
- package/.cursor/skills/deco-start-architecture/admin-protocol.md +156 -0
- package/.cursor/skills/deco-start-architecture/cms-resolution.md +201 -0
- package/.cursor/skills/deco-start-architecture/code-quality.md +158 -0
- package/.cursor/skills/deco-start-architecture/gap-analysis.md +129 -0
- package/.cursor/skills/deco-start-architecture/sdk-utilities.md +197 -0
- package/.cursor/skills/deco-start-architecture/worker-entry-caching.md +154 -0
- package/.cursor/skills/deco-startup-analysis/SKILL.md +248 -0
- package/.cursor/skills/deco-storefront-test-checklist/SKILL.md +369 -0
- package/.cursor/skills/deco-tanstack-hydration-fixes/SKILL.md +468 -0
- package/.cursor/skills/deco-tanstack-navigation/SKILL.md +681 -0
- package/.cursor/skills/deco-tanstack-search/SKILL.md +411 -0
- package/.cursor/skills/deco-tanstack-storefront-patterns/SKILL.md +1013 -0
- package/.cursor/skills/deco-to-tanstack-migration/SKILL.md +518 -0
- package/.cursor/skills/deco-to-tanstack-migration/references/codemod-commands.md +174 -0
- package/.cursor/skills/deco-to-tanstack-migration/references/commerce/README.md +78 -0
- package/.cursor/skills/deco-to-tanstack-migration/references/deco-framework/README.md +128 -0
- package/.cursor/skills/deco-to-tanstack-migration/references/gotchas.md +719 -0
- package/.cursor/skills/deco-to-tanstack-migration/references/imports/README.md +70 -0
- package/.cursor/skills/deco-to-tanstack-migration/references/platform-hooks/README.md +154 -0
- package/.cursor/skills/deco-to-tanstack-migration/references/signals/README.md +220 -0
- package/.cursor/skills/deco-to-tanstack-migration/references/vite-config/README.md +78 -0
- package/.cursor/skills/deco-to-tanstack-migration/templates/package-json.md +55 -0
- package/.cursor/skills/deco-to-tanstack-migration/templates/root-route.md +110 -0
- package/.cursor/skills/deco-to-tanstack-migration/templates/router.md +96 -0
- package/.cursor/skills/deco-to-tanstack-migration/templates/setup-ts.md +167 -0
- package/.cursor/skills/deco-to-tanstack-migration/templates/vite-config.md +122 -0
- package/.cursor/skills/deco-to-tanstack-migration/templates/worker-entry.md +67 -0
- package/.cursor/skills/deco-typescript-fixes/SKILL.md +178 -0
- package/.cursor/skills/deco-typescript-fixes/common-fixes.md +330 -0
- package/.cursor/skills/deco-typescript-fixes/strategy.md +148 -0
- package/.cursor/skills/deco-variant-selection-perf/SKILL.md +272 -0
- package/.cursor/skills/deco-vtex-fetch-cache/SKILL.md +225 -0
- package/.cursor/skills/find-skills/SKILL.md +133 -0
- package/.cursor/skills/incident-report/SKILL.md +179 -0
- package/.cursor/skills/incident-report/references/5-whys.md +75 -0
- package/.cursor/skills/incident-report/templates/client-report.md +187 -0
- package/.cursor/skills/incident-report/templates/internal-report.md +206 -0
- package/.cursor/skills/template-skill/SKILL.md +38 -0
- package/.github/workflows/release.yml +32 -0
- package/.releaserc.json +25 -0
- package/CLAUDE.md +135 -0
- package/GAP_ANALYSIS.md +224 -0
- package/GAP_ANALYSIS_V2.md +1013 -0
- package/biome.json +39 -0
- package/knip.json +5 -0
- package/package.json +87 -0
- package/scripts/generate-blocks.ts +69 -0
- package/scripts/generate-invoke.ts +378 -0
- package/scripts/generate-schema.ts +657 -0
- package/src/admin/cors.ts +29 -0
- package/src/admin/decofile.ts +72 -0
- package/src/admin/index.ts +24 -0
- package/src/admin/invoke.ts +163 -0
- package/src/admin/liveControls.ts +29 -0
- package/src/admin/meta.ts +70 -0
- package/src/admin/render.ts +205 -0
- package/src/admin/schema.ts +686 -0
- package/src/admin/setup.ts +44 -0
- package/src/cms/index.ts +59 -0
- package/src/cms/loader.ts +180 -0
- package/src/cms/registry.ts +162 -0
- package/src/cms/resolve.ts +1005 -0
- package/src/cms/sectionLoaders.ts +294 -0
- package/src/hooks/DecoPageRenderer.tsx +444 -0
- package/src/hooks/LazySection.tsx +109 -0
- package/src/hooks/LiveControls.tsx +108 -0
- package/src/hooks/SectionErrorFallback.tsx +85 -0
- package/src/hooks/index.ts +8 -0
- package/src/index.ts +5 -0
- package/src/matchers/builtins.ts +184 -0
- package/src/matchers/posthog.ts +154 -0
- package/src/middleware/decoState.ts +55 -0
- package/src/middleware/healthMetrics.ts +131 -0
- package/src/middleware/index.ts +80 -0
- package/src/middleware/liveness.ts +21 -0
- package/src/middleware/observability.ts +205 -0
- package/src/routes/adminRoutes.ts +83 -0
- package/src/routes/cmsRoute.ts +302 -0
- package/src/routes/components.tsx +34 -0
- package/src/routes/index.ts +15 -0
- package/src/sdk/analytics.ts +72 -0
- package/src/sdk/cacheHeaders.ts +268 -0
- package/src/sdk/cachedLoader.ts +206 -0
- package/src/sdk/clx.ts +3 -0
- package/src/sdk/cookie.ts +39 -0
- package/src/sdk/createInvoke.ts +57 -0
- package/src/sdk/csp.ts +59 -0
- package/src/sdk/env.ts +27 -0
- package/src/sdk/index.ts +63 -0
- package/src/sdk/instrumentedFetch.ts +137 -0
- package/src/sdk/invoke.ts +133 -0
- package/src/sdk/mergeCacheControl.ts +150 -0
- package/src/sdk/redirects.ts +217 -0
- package/src/sdk/requestContext.ts +184 -0
- package/src/sdk/serverTimings.ts +68 -0
- package/src/sdk/signal.ts +41 -0
- package/src/sdk/sitemap.ts +143 -0
- package/src/sdk/urlUtils.ts +117 -0
- package/src/sdk/useDevice.ts +82 -0
- package/src/sdk/useId.ts +7 -0
- package/src/sdk/useScript.ts +101 -0
- package/src/sdk/workerEntry.ts +703 -0
- package/src/sdk/wrapCaughtErrors.ts +107 -0
- package/src/types/index.ts +39 -0
- package/src/types/widgets.ts +13 -0
- package/tsconfig.json +13 -0
|
@@ -0,0 +1,918 @@
|
|
|
1
|
+
import type { Page, CDPSession, Response, Request } from '@playwright/test'
|
|
2
|
+
|
|
3
|
+
export interface PerformanceMetrics {
|
|
4
|
+
LCP: number | null
|
|
5
|
+
FCP: number | null
|
|
6
|
+
CLS: number | null
|
|
7
|
+
TTFB: number | null
|
|
8
|
+
domContentLoaded: number | null
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface NetworkMetrics {
|
|
12
|
+
totalRequests: number
|
|
13
|
+
totalBytes: number
|
|
14
|
+
totalBytesFormatted: string
|
|
15
|
+
slowestRequests: Array<{ url: string; duration: number; type: string }>
|
|
16
|
+
failedRequests: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Individual loader timing from Server-Timing header
|
|
21
|
+
*/
|
|
22
|
+
export interface LoaderTiming {
|
|
23
|
+
name: string
|
|
24
|
+
duration: number
|
|
25
|
+
status: string | null // 'bypass', 'HIT', 'MISS', 'STALE', etc.
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Server-side timing metrics from Deco's ?__d debug mode
|
|
30
|
+
*/
|
|
31
|
+
export interface ServerTimingMetrics {
|
|
32
|
+
loaders: LoaderTiming[]
|
|
33
|
+
totalServerTime: number
|
|
34
|
+
slowestLoaders: LoaderTiming[]
|
|
35
|
+
cacheStats: {
|
|
36
|
+
total: number
|
|
37
|
+
bypass: number
|
|
38
|
+
hit: number
|
|
39
|
+
miss: number
|
|
40
|
+
stale: number
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Lazy render request tracking (/deco/render)
|
|
46
|
+
*/
|
|
47
|
+
export interface LazyRenderRequest {
|
|
48
|
+
url: string
|
|
49
|
+
sectionName: string
|
|
50
|
+
duration: number
|
|
51
|
+
cached: boolean
|
|
52
|
+
cacheStatus: string | null // 'HIT' | 'MISS' | 'STALE' | 'DYNAMIC' | null
|
|
53
|
+
cacheControl: string | null
|
|
54
|
+
serverTiming: LoaderTiming[]
|
|
55
|
+
status: number
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Page cache analysis
|
|
60
|
+
*/
|
|
61
|
+
export interface CacheAnalysis {
|
|
62
|
+
pageUrl: string
|
|
63
|
+
pageCached: boolean
|
|
64
|
+
pageCacheControl: string | null
|
|
65
|
+
lazyRenders: LazyRenderRequest[]
|
|
66
|
+
lazyRendersCached: number
|
|
67
|
+
lazyRendersUncached: number
|
|
68
|
+
serverSideLoaders: LoaderTiming[]
|
|
69
|
+
warnings: string[]
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface PageMetrics {
|
|
73
|
+
url: string
|
|
74
|
+
pageName: string
|
|
75
|
+
timestamp: string
|
|
76
|
+
performance: PerformanceMetrics
|
|
77
|
+
network: NetworkMetrics
|
|
78
|
+
serverTiming: ServerTimingMetrics
|
|
79
|
+
cacheAnalysis: CacheAnalysis
|
|
80
|
+
renderTime: number
|
|
81
|
+
errors: string[]
|
|
82
|
+
/** Deco observability headers */
|
|
83
|
+
decoHeaders: {
|
|
84
|
+
page: string | null // x-deco-page - matched page block name
|
|
85
|
+
route: string | null // x-deco-route - matched route template
|
|
86
|
+
platform: string | null // x-deco-platform
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface NetworkEntry {
|
|
91
|
+
url: string
|
|
92
|
+
type: string
|
|
93
|
+
startTime: number
|
|
94
|
+
endTime?: number
|
|
95
|
+
size: number
|
|
96
|
+
status?: number
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Metrics collector for Deco e2e tests with Server-Timing and lazy render tracking
|
|
101
|
+
* Captures browser Web Vitals, server-side loader timings, and /deco/render patterns
|
|
102
|
+
*/
|
|
103
|
+
export class MetricsCollector {
|
|
104
|
+
private page: Page
|
|
105
|
+
private cdp: CDPSession | null = null
|
|
106
|
+
private requests = new Map<string, NetworkEntry>()
|
|
107
|
+
private errors: string[] = []
|
|
108
|
+
private startTime = 0
|
|
109
|
+
private serverTimingHeader: string | null = null
|
|
110
|
+
private pageCacheControl: string | null = null
|
|
111
|
+
private lazyRenders: LazyRenderRequest[] = []
|
|
112
|
+
private pendingLazyRenders = 0 // Track in-flight /deco/render requests
|
|
113
|
+
// Deco observability headers
|
|
114
|
+
private decoPageHeader: string | null = null
|
|
115
|
+
private decoRouteHeader: string | null = null
|
|
116
|
+
private decoPlatformHeader: string | null = null
|
|
117
|
+
|
|
118
|
+
constructor(page: Page) {
|
|
119
|
+
this.page = page
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async init(): Promise<void> {
|
|
123
|
+
// CDP for performance metrics
|
|
124
|
+
this.cdp = await this.page.context().newCDPSession(this.page)
|
|
125
|
+
await this.cdp.send('Performance.enable')
|
|
126
|
+
|
|
127
|
+
// Track network
|
|
128
|
+
this.page.on('request', (req: Request) => {
|
|
129
|
+
this.requests.set(req.url() + Date.now(), {
|
|
130
|
+
url: req.url(),
|
|
131
|
+
type: req.resourceType(),
|
|
132
|
+
startTime: Date.now(),
|
|
133
|
+
size: 0,
|
|
134
|
+
})
|
|
135
|
+
// Track pending lazy render requests
|
|
136
|
+
if (req.url().includes('/deco/render')) {
|
|
137
|
+
this.pendingLazyRenders++
|
|
138
|
+
}
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
this.page.on('response', async (res: Response) => {
|
|
142
|
+
const url = res.request().url()
|
|
143
|
+
const id = [...this.requests.keys()].find(k =>
|
|
144
|
+
this.requests.get(k)?.url === url &&
|
|
145
|
+
!this.requests.get(k)?.endTime
|
|
146
|
+
)
|
|
147
|
+
if (id) {
|
|
148
|
+
const entry = this.requests.get(id)!
|
|
149
|
+
entry.endTime = Date.now()
|
|
150
|
+
entry.status = res.status()
|
|
151
|
+
try {
|
|
152
|
+
const body = await res.body().catch(() => null)
|
|
153
|
+
entry.size = body?.length || parseInt(res.headers()['content-length'] || '0', 10)
|
|
154
|
+
} catch {}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const headers = res.headers()
|
|
158
|
+
|
|
159
|
+
// Capture Server-Timing header from main document response
|
|
160
|
+
if (res.request().resourceType() === 'document') {
|
|
161
|
+
const serverTiming = headers['server-timing']
|
|
162
|
+
if (serverTiming) {
|
|
163
|
+
this.serverTimingHeader = serverTiming
|
|
164
|
+
}
|
|
165
|
+
this.pageCacheControl = headers['cache-control'] || null
|
|
166
|
+
|
|
167
|
+
// Capture Deco observability headers
|
|
168
|
+
this.decoPageHeader = headers['x-deco-page'] || null
|
|
169
|
+
this.decoRouteHeader = headers['x-deco-route'] || null
|
|
170
|
+
this.decoPlatformHeader = headers['x-deco-platform'] || null
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Track /deco/render requests (lazy loading)
|
|
174
|
+
if (url.includes('/deco/render')) {
|
|
175
|
+
this.pendingLazyRenders = Math.max(0, this.pendingLazyRenders - 1)
|
|
176
|
+
|
|
177
|
+
const startTime = this.requests.get(id!)?.startTime || Date.now()
|
|
178
|
+
const endTime = Date.now()
|
|
179
|
+
const cacheControl = headers['cache-control'] || null
|
|
180
|
+
const serverTiming = headers['server-timing'] || ''
|
|
181
|
+
|
|
182
|
+
// Extract section name from x-deco-section header (preferred) or URL fallback
|
|
183
|
+
const decoSectionHeader = headers['x-deco-section']
|
|
184
|
+
const headerUsable = decoSectionHeader && !decoSectionHeader.includes('Rendering/')
|
|
185
|
+
const sectionName = headerUsable ? decoSectionHeader : this.extractSectionName(url)
|
|
186
|
+
|
|
187
|
+
// Parse server timing from this specific render request
|
|
188
|
+
const loaderTimings = this.parseServerTimingString(serverTiming)
|
|
189
|
+
|
|
190
|
+
// Check if cached
|
|
191
|
+
const cached = this.isCached(cacheControl, res.status())
|
|
192
|
+
|
|
193
|
+
// Get actual cache status from x-cache header
|
|
194
|
+
const xCache = headers['x-cache'] || null
|
|
195
|
+
|
|
196
|
+
this.lazyRenders.push({
|
|
197
|
+
url: url.substring(0, 100) + (url.length > 100 ? '...' : ''),
|
|
198
|
+
sectionName,
|
|
199
|
+
duration: endTime - startTime,
|
|
200
|
+
cached,
|
|
201
|
+
cacheStatus: xCache,
|
|
202
|
+
cacheControl,
|
|
203
|
+
serverTiming: loaderTimings,
|
|
204
|
+
status: res.status(),
|
|
205
|
+
})
|
|
206
|
+
}
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
this.page.on('requestfailed', (req: Request) => {
|
|
210
|
+
const url = req.url()
|
|
211
|
+
const id = [...this.requests.keys()].find(k =>
|
|
212
|
+
this.requests.get(k)?.url === url && !this.requests.get(k)?.endTime
|
|
213
|
+
)
|
|
214
|
+
if (id) {
|
|
215
|
+
const entry = this.requests.get(id)!
|
|
216
|
+
entry.endTime = Date.now()
|
|
217
|
+
entry.status = 0
|
|
218
|
+
}
|
|
219
|
+
if (url.includes('/deco/render')) {
|
|
220
|
+
this.pendingLazyRenders = Math.max(0, this.pendingLazyRenders - 1)
|
|
221
|
+
}
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
// Track errors
|
|
225
|
+
this.page.on('console', (msg) => {
|
|
226
|
+
if (msg.type() === 'error') this.errors.push(msg.text())
|
|
227
|
+
})
|
|
228
|
+
this.page.on('pageerror', (err) => this.errors.push(err.message))
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
private isCached(cacheControl: string | null, status: number): boolean {
|
|
232
|
+
if (status === 304) return true
|
|
233
|
+
if (!cacheControl) return false
|
|
234
|
+
|
|
235
|
+
const hasMaxAge = /max-age=\d+/.test(cacheControl) && !/max-age=0/.test(cacheControl)
|
|
236
|
+
const hasSMaxAge = /s-maxage=\d+/.test(cacheControl)
|
|
237
|
+
const noStore = /no-store/.test(cacheControl)
|
|
238
|
+
const noCache = /no-cache/.test(cacheControl)
|
|
239
|
+
|
|
240
|
+
return (hasMaxAge || hasSMaxAge) && !noStore && !noCache
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
private extractSectionName(url: string): string {
|
|
244
|
+
try {
|
|
245
|
+
const urlObj = new URL(url, 'http://localhost')
|
|
246
|
+
|
|
247
|
+
// Priority 1: Look in props for section type and title
|
|
248
|
+
const props = urlObj.searchParams.get('props')
|
|
249
|
+
if (props) {
|
|
250
|
+
try {
|
|
251
|
+
const parsed = JSON.parse(decodeURIComponent(props))
|
|
252
|
+
const nameFromProps = this.extractNameFromProps(parsed)
|
|
253
|
+
if (nameFromProps) return nameFromProps
|
|
254
|
+
} catch {}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Priority 2: sectionId parameter
|
|
258
|
+
const sectionId = urlObj.searchParams.get('sectionId')
|
|
259
|
+
if (sectionId) {
|
|
260
|
+
const cleaned = this.cleanName(sectionId)
|
|
261
|
+
if (cleaned) return cleaned
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Priority 3: resolveChain
|
|
265
|
+
const resolveChain = urlObj.searchParams.get('resolveChain')
|
|
266
|
+
if (resolveChain) {
|
|
267
|
+
try {
|
|
268
|
+
const chain = JSON.parse(decodeURIComponent(resolveChain))
|
|
269
|
+
if (Array.isArray(chain)) {
|
|
270
|
+
for (const item of chain) {
|
|
271
|
+
if (item.type === 'resolvable' && item.value) {
|
|
272
|
+
const val = String(item.value)
|
|
273
|
+
if (val.includes('sections/') || val.includes('Section')) {
|
|
274
|
+
const cleaned = this.cleanName(val)
|
|
275
|
+
if (cleaned) return cleaned
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
} catch {}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Priority 4: href parameter
|
|
284
|
+
const href = urlObj.searchParams.get('href')
|
|
285
|
+
if (href) {
|
|
286
|
+
try {
|
|
287
|
+
const hrefUrl = new URL(href, 'http://localhost')
|
|
288
|
+
const match = hrefUrl.pathname.match(/\/section\/([^/]+)/)
|
|
289
|
+
if (match) {
|
|
290
|
+
const cleaned = this.cleanName(decodeURIComponent(match[1]))
|
|
291
|
+
if (cleaned) return cleaned
|
|
292
|
+
}
|
|
293
|
+
} catch {}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Priority 5: pathname
|
|
297
|
+
const pathMatch = urlObj.pathname.match(/\/deco\/render\/([^/?]+)/)
|
|
298
|
+
if (pathMatch) {
|
|
299
|
+
const cleaned = this.cleanName(decodeURIComponent(pathMatch[1]))
|
|
300
|
+
if (cleaned) return cleaned
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Fallback: renderSalt
|
|
304
|
+
const renderSalt = urlObj.searchParams.get('renderSalt')
|
|
305
|
+
if (renderSalt) {
|
|
306
|
+
return `Section #${renderSalt}`
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return 'Unknown Section'
|
|
310
|
+
} catch {
|
|
311
|
+
return 'Unknown Section'
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Extract a meaningful name from section props
|
|
317
|
+
*/
|
|
318
|
+
private extractNameFromProps(props: Record<string, unknown>): string | null {
|
|
319
|
+
const section = props.section as Record<string, unknown> | undefined
|
|
320
|
+
const target = section || props
|
|
321
|
+
|
|
322
|
+
const resolveType = (target.__resolveType || target['__resolveType']) as string | undefined
|
|
323
|
+
const componentName = resolveType ? this.cleanName(resolveType) : null
|
|
324
|
+
|
|
325
|
+
const title = (target.title || target.name || target.label) as string | undefined
|
|
326
|
+
|
|
327
|
+
const nestedTitle = (
|
|
328
|
+
(target.props as Record<string, unknown>)?.title ||
|
|
329
|
+
(target.content as Record<string, unknown>)?.title ||
|
|
330
|
+
(target.header as Record<string, unknown>)?.title
|
|
331
|
+
) as string | undefined
|
|
332
|
+
|
|
333
|
+
const displayTitle = title || nestedTitle
|
|
334
|
+
|
|
335
|
+
if (componentName && displayTitle) {
|
|
336
|
+
const shortTitle = displayTitle.length > 20 ? displayTitle.substring(0, 17) + '...' : displayTitle
|
|
337
|
+
return `${componentName}: ${shortTitle}`
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (displayTitle) {
|
|
341
|
+
return displayTitle.length > 30 ? displayTitle.substring(0, 27) + '...' : displayTitle
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (componentName) {
|
|
345
|
+
return componentName
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const collection = (
|
|
349
|
+
(target.products as Record<string, unknown>)?.props as Record<string, unknown>
|
|
350
|
+
)?.collection as string | undefined
|
|
351
|
+
|
|
352
|
+
if (collection) {
|
|
353
|
+
return `Products: ${collection.substring(0, 20)}`
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
return null
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
private cleanName(name: string): string {
|
|
360
|
+
if (name.includes('Rendering/Lazy') || name.includes('Rendering/Deferred')) {
|
|
361
|
+
return ''
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return name
|
|
365
|
+
.replace(/^site\//, '')
|
|
366
|
+
.replace(/^website\//, '')
|
|
367
|
+
.replace(/\.tsx$/, '')
|
|
368
|
+
.replace(/^sections\//, '')
|
|
369
|
+
.replace(/^islands\//, '')
|
|
370
|
+
.replace(/Rendering\//, '')
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
startMeasurement(): void {
|
|
374
|
+
this.requests.clear()
|
|
375
|
+
this.errors = []
|
|
376
|
+
this.serverTimingHeader = null
|
|
377
|
+
this.pageCacheControl = null
|
|
378
|
+
this.lazyRenders = []
|
|
379
|
+
this.pendingLazyRenders = 0
|
|
380
|
+
this.startTime = Date.now()
|
|
381
|
+
this.decoPageHeader = null
|
|
382
|
+
this.decoRouteHeader = null
|
|
383
|
+
this.decoPlatformHeader = null
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Dismiss any popups/modals that might block scrolling
|
|
388
|
+
*/
|
|
389
|
+
private async dismissPopups(): Promise<void> {
|
|
390
|
+
await this.page.evaluate(() => {
|
|
391
|
+
document.querySelectorAll('[class*="pushnews"], [id*="pushnews"], [class*="pn-"]').forEach(el => el.remove())
|
|
392
|
+
|
|
393
|
+
document.querySelectorAll('button').forEach(btn => {
|
|
394
|
+
const text = btn.textContent || ''
|
|
395
|
+
if (text.includes('obrigado') || text.includes('quero') || text.includes('Aceitar')) {
|
|
396
|
+
let parent = btn.parentElement
|
|
397
|
+
for (let i = 0; i < 10 && parent; i++) {
|
|
398
|
+
const style = window.getComputedStyle(parent)
|
|
399
|
+
if (style.position === 'fixed' || style.position === 'absolute') {
|
|
400
|
+
parent.remove()
|
|
401
|
+
break
|
|
402
|
+
}
|
|
403
|
+
parent = parent.parentElement
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
})
|
|
407
|
+
}).catch(() => {})
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Scroll down the page to trigger lazy loading
|
|
412
|
+
* STRICT QUEUE: Only one /deco/render at a time
|
|
413
|
+
*/
|
|
414
|
+
async scrollPage(options: { full?: boolean; footerSelector?: string; maxTime?: number } = {}): Promise<number> {
|
|
415
|
+
const { full = false, footerSelector = 'footer', maxTime = 30000 } = options
|
|
416
|
+
const initialRenders = this.lazyRenders.length
|
|
417
|
+
const startTime = Date.now()
|
|
418
|
+
|
|
419
|
+
await this.page.waitForTimeout(1000)
|
|
420
|
+
await this.dismissPopups()
|
|
421
|
+
|
|
422
|
+
if (!full) {
|
|
423
|
+
for (let i = 0; i < 3; i++) {
|
|
424
|
+
await this.page.evaluate(() => window.scrollBy(0, 500)).catch(() => {})
|
|
425
|
+
await this.page.waitForTimeout(300)
|
|
426
|
+
}
|
|
427
|
+
return this.lazyRenders.length - initialRenders
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const scrollStep = 200
|
|
431
|
+
let stuckSectionCount = 0
|
|
432
|
+
const maxStuckSections = 2
|
|
433
|
+
|
|
434
|
+
for (let i = 0; i < 300; i++) {
|
|
435
|
+
if (this.page.isClosed()) break
|
|
436
|
+
|
|
437
|
+
const elapsed = Date.now() - startTime
|
|
438
|
+
if (elapsed > maxTime) {
|
|
439
|
+
console.log(` ⏱️ Scroll timeout (${Math.round(elapsed / 1000)}s) - stopping`)
|
|
440
|
+
break
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if (this.pendingLazyRenders > 0) {
|
|
444
|
+
console.log(` ⏳ Waiting for ${this.pendingLazyRenders} pending render before next scroll...`)
|
|
445
|
+
await this.waitForPendingRenders(8000)
|
|
446
|
+
|
|
447
|
+
if (this.pendingLazyRenders > 0) {
|
|
448
|
+
stuckSectionCount++
|
|
449
|
+
console.log(` ⚠️ Section STUCK (${stuckSectionCount}/${maxStuckSections}) - will skip`)
|
|
450
|
+
this.pendingLazyRenders = 0
|
|
451
|
+
|
|
452
|
+
if (stuckSectionCount >= maxStuckSections) {
|
|
453
|
+
console.log(` 🛑 Too many stuck sections - stopping scroll`)
|
|
454
|
+
break
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
await this.page.waitForTimeout(1000)
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
await this.page.waitForTimeout(500)
|
|
461
|
+
continue
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const footerVisible = await this.page.locator(footerSelector).first().isVisible().catch(() => false)
|
|
465
|
+
if (footerVisible) {
|
|
466
|
+
console.log(` ✅ Footer visible after ${i} scrolls`)
|
|
467
|
+
break
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
if (i < 5) await this.dismissPopups()
|
|
471
|
+
|
|
472
|
+
await this.page.evaluate((step) => window.scrollBy(0, step), scrollStep).catch(() => {})
|
|
473
|
+
await this.page.waitForTimeout(100)
|
|
474
|
+
|
|
475
|
+
const atBottom = await this.page.evaluate(() => {
|
|
476
|
+
return window.scrollY + window.innerHeight >= document.documentElement.scrollHeight - 20
|
|
477
|
+
}).catch(() => false)
|
|
478
|
+
|
|
479
|
+
if (atBottom) {
|
|
480
|
+
if (this.pendingLazyRenders > 0) {
|
|
481
|
+
await this.waitForPendingRenders(3000)
|
|
482
|
+
this.pendingLazyRenders = 0
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const footerNow = await this.page.locator(footerSelector).first().isVisible().catch(() => false)
|
|
486
|
+
console.log(footerNow ? ` ✅ Footer visible at bottom` : ` ⚠️ At bottom, no footer`)
|
|
487
|
+
break
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
return this.lazyRenders.length - initialRenders
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
private async waitForPendingRenders(maxWait: number): Promise<void> {
|
|
495
|
+
const startTime = Date.now()
|
|
496
|
+
|
|
497
|
+
while (this.pendingLazyRenders > 0 && Date.now() - startTime < maxWait) {
|
|
498
|
+
await this.page.waitForTimeout(100)
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
if (this.pendingLazyRenders > 0) {
|
|
502
|
+
console.log(` ⚠️ Timeout waiting for ${this.pendingLazyRenders} render(s)`)
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
async collectPageMetrics(pageName: string): Promise<PageMetrics> {
|
|
507
|
+
if (this.page.isClosed()) {
|
|
508
|
+
console.log(' ⚠️ Page was closed before collecting metrics')
|
|
509
|
+
return this.getEmptyMetrics(pageName)
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
await this.page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {})
|
|
513
|
+
if (this.page.isClosed()) return this.getEmptyMetrics(pageName)
|
|
514
|
+
await this.page.waitForTimeout(500).catch(() => {})
|
|
515
|
+
|
|
516
|
+
const serverTiming = this.parseServerTiming()
|
|
517
|
+
const cacheAnalysis = this.analyzeCaching(serverTiming)
|
|
518
|
+
|
|
519
|
+
return {
|
|
520
|
+
url: this.page.url(),
|
|
521
|
+
pageName,
|
|
522
|
+
timestamp: new Date().toISOString(),
|
|
523
|
+
performance: await this.getPerformance(),
|
|
524
|
+
network: this.getNetwork(),
|
|
525
|
+
serverTiming,
|
|
526
|
+
cacheAnalysis,
|
|
527
|
+
renderTime: Date.now() - this.startTime,
|
|
528
|
+
errors: [...this.errors],
|
|
529
|
+
decoHeaders: {
|
|
530
|
+
page: this.decoPageHeader,
|
|
531
|
+
route: this.decoRouteHeader,
|
|
532
|
+
platform: this.decoPlatformHeader,
|
|
533
|
+
},
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
private analyzeCaching(serverTiming: ServerTimingMetrics): CacheAnalysis {
|
|
538
|
+
const warnings: string[] = []
|
|
539
|
+
const pageCached = this.isCached(this.pageCacheControl, 200)
|
|
540
|
+
|
|
541
|
+
const lazyRendersCached = this.lazyRenders.filter(r => r.cached).length
|
|
542
|
+
const lazyRendersUncached = this.lazyRenders.filter(r => !r.cached).length
|
|
543
|
+
|
|
544
|
+
const serverSideLoaders = serverTiming.slowestLoaders.filter(l => l.duration > 0)
|
|
545
|
+
|
|
546
|
+
if (!pageCached && serverSideLoaders.length > 0) {
|
|
547
|
+
const slowLoaders = serverSideLoaders.filter(l => l.duration > 50)
|
|
548
|
+
if (slowLoaders.length > 0) {
|
|
549
|
+
warnings.push(
|
|
550
|
+
`⚠️ Page is NOT cached but has ${slowLoaders.length} slow loader(s) on SSR. ` +
|
|
551
|
+
`Consider moving loaders to lazy sections or adding cache.`
|
|
552
|
+
)
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
if (serverSideLoaders.length > 10) {
|
|
557
|
+
warnings.push(
|
|
558
|
+
`⚠️ ${serverSideLoaders.length} loaders running on SSR. ` +
|
|
559
|
+
`Consider lazy loading more sections.`
|
|
560
|
+
)
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
if (lazyRendersUncached > 0) {
|
|
564
|
+
warnings.push(
|
|
565
|
+
`⚠️ ${lazyRendersUncached} lazy render(s) without cache. ` +
|
|
566
|
+
`Add cache-control headers.`
|
|
567
|
+
)
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const verySlowLoaders = serverSideLoaders.filter(l => l.duration > 200)
|
|
571
|
+
if (verySlowLoaders.length > 0) {
|
|
572
|
+
warnings.push(
|
|
573
|
+
`🐢 ${verySlowLoaders.length} very slow loader(s) (>200ms) on SSR: ` +
|
|
574
|
+
verySlowLoaders.map(l => `${l.name} (${l.duration}ms)`).join(', ')
|
|
575
|
+
)
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
return {
|
|
579
|
+
pageUrl: this.page.url(),
|
|
580
|
+
pageCached,
|
|
581
|
+
pageCacheControl: this.pageCacheControl,
|
|
582
|
+
lazyRenders: [...this.lazyRenders],
|
|
583
|
+
lazyRendersCached,
|
|
584
|
+
lazyRendersUncached,
|
|
585
|
+
serverSideLoaders,
|
|
586
|
+
warnings,
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
private parseServerTiming(): ServerTimingMetrics {
|
|
591
|
+
return this.parseServerTimingToMetrics(this.serverTimingHeader)
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
private parseServerTimingToMetrics(header: string | null): ServerTimingMetrics {
|
|
595
|
+
const loaders: LoaderTiming[] = []
|
|
596
|
+
const cacheStats = { total: 0, bypass: 0, hit: 0, miss: 0, stale: 0 }
|
|
597
|
+
|
|
598
|
+
if (!header) {
|
|
599
|
+
return {
|
|
600
|
+
loaders: [],
|
|
601
|
+
totalServerTime: 0,
|
|
602
|
+
slowestLoaders: [],
|
|
603
|
+
cacheStats,
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
const entries = header.split(/,\s*/)
|
|
608
|
+
|
|
609
|
+
for (const entry of entries) {
|
|
610
|
+
const parsed = this.parseServerTimingEntry(entry.trim())
|
|
611
|
+
if (parsed) {
|
|
612
|
+
loaders.push(parsed)
|
|
613
|
+
cacheStats.total++
|
|
614
|
+
|
|
615
|
+
if (parsed.status) {
|
|
616
|
+
const statusLower = parsed.status.toLowerCase()
|
|
617
|
+
if (statusLower === 'bypass') cacheStats.bypass++
|
|
618
|
+
else if (statusLower === 'hit') cacheStats.hit++
|
|
619
|
+
else if (statusLower === 'miss') cacheStats.miss++
|
|
620
|
+
else if (statusLower === 'stale') cacheStats.stale++
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
const totalServerTime = loaders
|
|
626
|
+
.filter(l => l.name !== 'render-to-string')
|
|
627
|
+
.reduce((sum, l) => sum + l.duration, 0)
|
|
628
|
+
|
|
629
|
+
const slowestLoaders = [...loaders]
|
|
630
|
+
.filter(l => !['router', 'render-to-string', 'load-data', 'cfExtPri'].includes(l.name))
|
|
631
|
+
.sort((a, b) => b.duration - a.duration)
|
|
632
|
+
.slice(0, 10)
|
|
633
|
+
|
|
634
|
+
return {
|
|
635
|
+
loaders,
|
|
636
|
+
totalServerTime,
|
|
637
|
+
slowestLoaders,
|
|
638
|
+
cacheStats,
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
private parseServerTimingString(header: string): LoaderTiming[] {
|
|
643
|
+
if (!header) return []
|
|
644
|
+
|
|
645
|
+
const entries = header.split(/,\s*/)
|
|
646
|
+
return entries
|
|
647
|
+
.map(entry => this.parseServerTimingEntry(entry.trim()))
|
|
648
|
+
.filter((e): e is LoaderTiming => e !== null)
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
private parseServerTimingEntry(entry: string): LoaderTiming | null {
|
|
652
|
+
if (!entry) return null
|
|
653
|
+
|
|
654
|
+
const parts = entry.split(';')
|
|
655
|
+
if (parts.length === 0) return null
|
|
656
|
+
|
|
657
|
+
const rawName = parts[0]
|
|
658
|
+
const name = this.decodeLoaderName(rawName)
|
|
659
|
+
|
|
660
|
+
let duration = 0
|
|
661
|
+
let status: string | null = null
|
|
662
|
+
|
|
663
|
+
for (let i = 1; i < parts.length; i++) {
|
|
664
|
+
const part = parts[i].trim()
|
|
665
|
+
if (part.startsWith('dur=')) {
|
|
666
|
+
duration = parseFloat(part.substring(4)) || 0
|
|
667
|
+
} else if (part.startsWith('desc=')) {
|
|
668
|
+
status = part.substring(5).replace(/^"|"$/g, '')
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
return { name, duration, status }
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
private decodeLoaderName(raw: string): string {
|
|
676
|
+
try {
|
|
677
|
+
let decoded = decodeURIComponent(raw)
|
|
678
|
+
try {
|
|
679
|
+
decoded = decodeURIComponent(decoded)
|
|
680
|
+
} catch {}
|
|
681
|
+
|
|
682
|
+
decoded = decoded
|
|
683
|
+
.replace(/@/g, ' → ')
|
|
684
|
+
.replace(/\.variants\.\d+\.value\./g, '.')
|
|
685
|
+
.replace(/\.section\./g, '.')
|
|
686
|
+
.replace(/pages-/g, '')
|
|
687
|
+
.replace(/-[a-f0-9]{8,}/g, '')
|
|
688
|
+
|
|
689
|
+
return decoded
|
|
690
|
+
} catch {
|
|
691
|
+
return raw
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
private async getPerformance(): Promise<PerformanceMetrics> {
|
|
696
|
+
const data = await this.page.evaluate(() => {
|
|
697
|
+
const nav = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming
|
|
698
|
+
const fcp = performance.getEntriesByType('paint').find(e => e.name === 'first-contentful-paint')
|
|
699
|
+
const lcpEntries = performance.getEntriesByType('largest-contentful-paint')
|
|
700
|
+
const lcp = lcpEntries[lcpEntries.length - 1] as PerformanceEntry & { startTime: number }
|
|
701
|
+
|
|
702
|
+
const layoutShifts = performance.getEntriesByType('layout-shift') as Array<PerformanceEntry & { value: number; hadRecentInput: boolean }>
|
|
703
|
+
const cls = layoutShifts.filter(e => !e.hadRecentInput).reduce((sum, e) => sum + e.value, 0)
|
|
704
|
+
|
|
705
|
+
return {
|
|
706
|
+
TTFB: nav?.responseStart - nav?.requestStart || null,
|
|
707
|
+
domContentLoaded: nav?.domContentLoadedEventEnd - nav?.startTime || null,
|
|
708
|
+
FCP: fcp?.startTime || null,
|
|
709
|
+
LCP: lcp?.startTime || null,
|
|
710
|
+
CLS: cls,
|
|
711
|
+
}
|
|
712
|
+
})
|
|
713
|
+
|
|
714
|
+
return {
|
|
715
|
+
TTFB: data.TTFB,
|
|
716
|
+
FCP: data.FCP,
|
|
717
|
+
LCP: data.LCP,
|
|
718
|
+
CLS: data.CLS,
|
|
719
|
+
domContentLoaded: data.domContentLoaded,
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
private getNetwork(): NetworkMetrics {
|
|
724
|
+
const entries = [...this.requests.values()]
|
|
725
|
+
const totalBytes = entries.reduce((sum, e) => sum + e.size, 0)
|
|
726
|
+
|
|
727
|
+
const slowest = entries
|
|
728
|
+
.filter(e => e.endTime)
|
|
729
|
+
.map(e => ({
|
|
730
|
+
url: e.url.slice(0, 80),
|
|
731
|
+
duration: (e.endTime || 0) - e.startTime,
|
|
732
|
+
type: e.type,
|
|
733
|
+
}))
|
|
734
|
+
.sort((a, b) => b.duration - a.duration)
|
|
735
|
+
.slice(0, 5)
|
|
736
|
+
|
|
737
|
+
return {
|
|
738
|
+
totalRequests: entries.length,
|
|
739
|
+
totalBytes,
|
|
740
|
+
totalBytesFormatted: this.formatBytes(totalBytes),
|
|
741
|
+
slowestRequests: slowest,
|
|
742
|
+
failedRequests: entries.filter(e => e.status === 0).length,
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
private formatBytes(bytes: number): string {
|
|
747
|
+
if (bytes === 0) return '0 B'
|
|
748
|
+
const k = 1024
|
|
749
|
+
const sizes = ['B', 'KB', 'MB']
|
|
750
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
|
751
|
+
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
async cleanup(): Promise<void> {
|
|
755
|
+
await this.cdp?.detach().catch(() => {})
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
private getEmptyMetrics(pageName: string): PageMetrics {
|
|
759
|
+
return {
|
|
760
|
+
url: 'page-closed',
|
|
761
|
+
pageName,
|
|
762
|
+
timestamp: new Date().toISOString(),
|
|
763
|
+
performance: { LCP: null, FCP: null, CLS: null, TTFB: null, domContentLoaded: null },
|
|
764
|
+
network: { totalRequests: 0, totalBytes: 0, totalBytesFormatted: '0 B', slowestRequests: [], failedRequests: 0 },
|
|
765
|
+
serverTiming: { loaders: [], totalServerTime: 0, slowestLoaders: [], cacheStats: { total: 0, bypass: 0, hit: 0, miss: 0, stale: 0 } },
|
|
766
|
+
cacheAnalysis: { pageUrl: 'page-closed', pageCached: false, pageCacheControl: null, lazyRenders: [], lazyRendersCached: 0, lazyRendersUncached: 0, serverSideLoaders: [], warnings: ['Page was closed before metrics collection'] },
|
|
767
|
+
renderTime: 0,
|
|
768
|
+
errors: ['Page was closed'],
|
|
769
|
+
decoHeaders: { page: null, route: null, platform: null },
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* Format loader timings for beautiful console output
|
|
776
|
+
*/
|
|
777
|
+
export function formatLoaderTimings(serverTiming: ServerTimingMetrics): string[] {
|
|
778
|
+
const lines: string[] = []
|
|
779
|
+
|
|
780
|
+
if (serverTiming.loaders.length === 0) {
|
|
781
|
+
lines.push(' ⚡ Server Timing: 0ms total (1 loaders)')
|
|
782
|
+
return lines
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
const { bypass, hit, miss, stale } = serverTiming.cacheStats
|
|
786
|
+
const cacheInfo: string[] = []
|
|
787
|
+
if (hit > 0) cacheInfo.push(`💾${hit}`)
|
|
788
|
+
if (miss > 0) cacheInfo.push(`❌${miss}`)
|
|
789
|
+
if (stale > 0) cacheInfo.push(`⏳${stale}`)
|
|
790
|
+
if (bypass > 0) cacheInfo.push(`⏭️${bypass}`)
|
|
791
|
+
|
|
792
|
+
const cacheStr = cacheInfo.length > 0 ? ` [${cacheInfo.join(' ')}]` : ''
|
|
793
|
+
lines.push(` ⚡ Server Timing: ${serverTiming.totalServerTime.toFixed(0)}ms total (${serverTiming.loaders.length} loaders)${cacheStr}`)
|
|
794
|
+
|
|
795
|
+
if (serverTiming.loaders.length > 0) {
|
|
796
|
+
lines.push(' ┌───────────────────────────────────────────────────────────')
|
|
797
|
+
|
|
798
|
+
const sorted = [...serverTiming.loaders].sort((a, b) => b.duration - a.duration)
|
|
799
|
+
|
|
800
|
+
for (const loader of sorted.slice(0, 12)) {
|
|
801
|
+
const speedIcon = loader.duration < 50 ? '🟢' : loader.duration < 200 ? '🟡' : '🔴'
|
|
802
|
+
let cacheIcon = ' '
|
|
803
|
+
if (loader.status === 'HIT') cacheIcon = '💾'
|
|
804
|
+
else if (loader.status === 'MISS') cacheIcon = '❌'
|
|
805
|
+
else if (loader.status === 'STALE') cacheIcon = '⏳'
|
|
806
|
+
else if (loader.status === 'bypass') cacheIcon = '⏭️'
|
|
807
|
+
|
|
808
|
+
const name = loader.name.length > 30 ? loader.name.substring(0, 27) + '...' : loader.name.padEnd(30)
|
|
809
|
+
const status = loader.status ? `[${loader.status}]` : ''
|
|
810
|
+
|
|
811
|
+
lines.push(` │ ${speedIcon} ${name} ${loader.duration.toFixed(0).padStart(5)}ms ${cacheIcon} ${status}`)
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
if (serverTiming.loaders.length > 12) {
|
|
815
|
+
lines.push(` │ ... and ${serverTiming.loaders.length - 12} more loaders`)
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
lines.push(' └───────────────────────────────────────────────────────────')
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
return lines
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/**
|
|
825
|
+
* Format lazy render analysis for console output
|
|
826
|
+
*/
|
|
827
|
+
export function formatLazyRenderAnalysis(cacheAnalysis: CacheAnalysis): string[] {
|
|
828
|
+
const lines: string[] = []
|
|
829
|
+
|
|
830
|
+
const pageIcon = cacheAnalysis.pageCached ? '✅' : '❌'
|
|
831
|
+
lines.push(` ${pageIcon} Page Cache: ${cacheAnalysis.pageCached ? 'CACHED' : 'NOT CACHED'}`)
|
|
832
|
+
if (cacheAnalysis.pageCacheControl && cacheAnalysis.pageCached) {
|
|
833
|
+
lines.push(` Cache-Control: ${cacheAnalysis.pageCacheControl.substring(0, 60)}...`)
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
if (cacheAnalysis.serverSideLoaders.length > 0) {
|
|
837
|
+
lines.push('')
|
|
838
|
+
lines.push(` 🖥️ SSR Loaders (${cacheAnalysis.serverSideLoaders.length}):`)
|
|
839
|
+
lines.push(' ┌───────────────────────────────────────────────────────────')
|
|
840
|
+
for (const loader of cacheAnalysis.serverSideLoaders.slice(0, 10)) {
|
|
841
|
+
const speedIcon = loader.duration < 50 ? '🟢' : loader.duration < 200 ? '🟡' : '🔴'
|
|
842
|
+
const cacheIcon = loader.status === 'HIT' ? '💾' : loader.status === 'STALE' ? '⏳' : loader.status === 'MISS' ? '❌' : '⏭️'
|
|
843
|
+
const name = loader.name.length > 30 ? loader.name.substring(0, 27) + '...' : loader.name.padEnd(30)
|
|
844
|
+
const status = loader.status ? `[${loader.status}]` : ''
|
|
845
|
+
lines.push(` │ ${speedIcon} ${name} ${loader.duration.toString().padStart(4)}ms ${cacheIcon} ${status}`)
|
|
846
|
+
}
|
|
847
|
+
if (cacheAnalysis.serverSideLoaders.length > 10) {
|
|
848
|
+
lines.push(` │ ... and ${cacheAnalysis.serverSideLoaders.length - 10} more loaders`)
|
|
849
|
+
}
|
|
850
|
+
lines.push(' └───────────────────────────────────────────────────────────')
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
if (cacheAnalysis.lazyRenders.length > 0) {
|
|
854
|
+
lines.push('')
|
|
855
|
+
lines.push(` 🔄 Lazy Sections (${cacheAnalysis.lazyRenders.length}):`)
|
|
856
|
+
lines.push(' ┌───────────────────────────────────────────────────────────')
|
|
857
|
+
|
|
858
|
+
const sorted = [...cacheAnalysis.lazyRenders].sort((a, b) => b.duration - a.duration)
|
|
859
|
+
|
|
860
|
+
for (const render of sorted.slice(0, 15)) {
|
|
861
|
+
const speedIcon = render.duration < 100 ? '🟢' : render.duration < 500 ? '🟡' : '🔴'
|
|
862
|
+
let cacheIcon = ' '
|
|
863
|
+
let cacheText = ''
|
|
864
|
+
if (render.cacheStatus) {
|
|
865
|
+
cacheText = render.cacheStatus
|
|
866
|
+
if (render.cacheStatus === 'HIT') cacheIcon = '💾'
|
|
867
|
+
else if (render.cacheStatus === 'MISS') cacheIcon = '❌'
|
|
868
|
+
else if (render.cacheStatus === 'STALE') cacheIcon = '⏳'
|
|
869
|
+
else if (render.cacheStatus === 'DYNAMIC') cacheIcon = '🔄'
|
|
870
|
+
} else if (render.cached) {
|
|
871
|
+
cacheIcon = '💾'
|
|
872
|
+
cacheText = 'cached'
|
|
873
|
+
}
|
|
874
|
+
const name = render.sectionName.length > 26 ? render.sectionName.substring(0, 23) + '...' : render.sectionName.padEnd(26)
|
|
875
|
+
|
|
876
|
+
lines.push(` │ ${speedIcon} ${name} ${render.duration.toString().padStart(5)}ms ${cacheIcon} ${cacheText}`)
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
if (cacheAnalysis.lazyRenders.length > 15) {
|
|
880
|
+
lines.push(` │ ... and ${cacheAnalysis.lazyRenders.length - 15} more sections`)
|
|
881
|
+
}
|
|
882
|
+
lines.push(' └───────────────────────────────────────────────────────────')
|
|
883
|
+
|
|
884
|
+
const fast = cacheAnalysis.lazyRenders.filter(r => r.duration < 100).length
|
|
885
|
+
const medium = cacheAnalysis.lazyRenders.filter(r => r.duration >= 100 && r.duration < 500).length
|
|
886
|
+
const slow = cacheAnalysis.lazyRenders.filter(r => r.duration >= 500).length
|
|
887
|
+
const totalTime = cacheAnalysis.lazyRenders.reduce((sum, r) => sum + r.duration, 0)
|
|
888
|
+
|
|
889
|
+
lines.push(` 📊 Summary: ${fast} fast, ${medium} medium, ${slow} slow │ Total: ${totalTime}ms`)
|
|
890
|
+
|
|
891
|
+
if (cacheAnalysis.lazyRendersUncached > 0) {
|
|
892
|
+
lines.push(` ⚠️ ${cacheAnalysis.lazyRendersUncached} uncached - add cache headers!`)
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
if (cacheAnalysis.warnings.length > 0) {
|
|
897
|
+
lines.push('')
|
|
898
|
+
lines.push(' ⚠️ WARNINGS:')
|
|
899
|
+
for (const warning of cacheAnalysis.warnings) {
|
|
900
|
+
lines.push(` ${warning}`)
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
return lines
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/**
|
|
908
|
+
* Get a summary of loader timings for compact display
|
|
909
|
+
*/
|
|
910
|
+
export function getLoaderSummary(serverTiming: ServerTimingMetrics): string {
|
|
911
|
+
if (serverTiming.loaders.length === 0) return 'No timing data'
|
|
912
|
+
|
|
913
|
+
const count = serverTiming.loaders.length
|
|
914
|
+
const slowest = serverTiming.slowestLoaders[0]
|
|
915
|
+
const slowestInfo = slowest ? `slowest: ${slowest.name.substring(0, 20)}... (${slowest.duration}ms)` : ''
|
|
916
|
+
|
|
917
|
+
return `${count} loaders, ${serverTiming.totalServerTime}ms total${slowestInfo ? ', ' + slowestInfo : ''}`
|
|
918
|
+
}
|