Files
Tommy D. Rossi d1346e7ca4 docs: add profano CPU profiling tips to performance resource
Mention .cpuprofile recording via CDP Profiler.* commands and
profano CLI for inspecting hot functions when vitals alone are
not enough to diagnose performance issues.

Session: ses_208943d84ffenqdbjxK0cO0iak
2026-05-05 11:39:01 +02:00

511 lines
16 KiB
TypeScript

/**
* Generates markdown resource files for the MCP at build time.
*
* These files are written to:
* - playwriter/dist/ - for the MCP to read at runtime
* - website/public/ - for hosting on playwriter.dev
*
* Source of truth:
* - playwriter/src/skill.md - manually edited, contains full docs including CLI usage
* - skills/playwriter/SKILL.md - stub with frontmatter for agent discovery
*
* Generated files:
* - playwriter/dist/prompt.md - MCP prompt (skill.md minus CLI sections)
* - website/public/SKILL.md - full copy for playwriter.dev/SKILL.md
* - website/public/.well-known/skills/index.json - Agent Skills Discovery endpoint
* - website/public/.well-known/skills/playwriter/SKILL.md - skill file with frontmatter
*/
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import dedent from 'string-dedent'
import { Lexer, type Token, type Tokens } from 'marked'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const playwriterDir = path.join(__dirname, '..')
const distDir = path.join(playwriterDir, 'dist')
const websitePublicDir = path.join(playwriterDir, '..', 'website', 'public', 'resources')
function ensureDir(dir: string) {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true })
}
}
function readFile(relativePath: string): string {
return fs.readFileSync(path.join(playwriterDir, relativePath), 'utf-8')
}
function writeToDestinations(filename: string, content: string) {
ensureDir(distDir)
ensureDir(websitePublicDir)
const distPath = path.join(distDir, filename)
const websitePath = path.join(websitePublicDir, filename)
fs.writeFileSync(distPath, content, 'utf-8')
fs.writeFileSync(websitePath, content, 'utf-8')
console.log(`Generated ${filename}`)
}
function cleanTypes(typesContent: string): string {
return typesContent.replace(/\/\/# sourceMappingURL=.*$/gm, '').trim()
}
function buildDebuggerApi() {
const debuggerTypes = cleanTypes(readFile('dist/debugger.d.ts'))
const debuggerExamples = readFile('src/debugger-examples.ts')
const content = dedent`
# Debugger API Reference
## Types
\`\`\`ts
${debuggerTypes}
\`\`\`
## Examples
\`\`\`ts
${debuggerExamples}
\`\`\`
`
writeToDestinations('debugger-api.md', content)
}
function buildEditorApi() {
const editorTypes = cleanTypes(readFile('dist/editor.d.ts'))
const editorExamples = readFile('src/editor-examples.ts')
const content = dedent`
# Editor API Reference
The Editor class provides a Claude Code-like interface for viewing and editing web page scripts at runtime.
## Types
\`\`\`ts
${editorTypes}
\`\`\`
## Examples
\`\`\`ts
${editorExamples}
\`\`\`
`
writeToDestinations('editor-api.md', content)
}
function buildStylesApi() {
const stylesTypes = cleanTypes(readFile('dist/styles.d.ts'))
const stylesExamples = readFile('src/styles-examples.ts')
const content = dedent`
# Styles API Reference
The getStylesForLocator function inspects CSS styles applied to an element, similar to browser DevTools "Styles" panel.
## Types
\`\`\`ts
${stylesTypes}
\`\`\`
## Examples
\`\`\`ts
${stylesExamples}
\`\`\`
`
writeToDestinations('styles-api.md', content)
}
function buildPerformanceProfiling() {
const performanceExamples = readFile('src/performance-examples.ts')
const content = dedent`
# Profile Website Performance with Playwriter
Playwriter can profile a real website in your own Chrome using **CDP**, **Navigation Timing**,
and **PerformanceObserver**.
Use it to answer four practical questions quickly:
1. **Did the page render fast enough?**
2. **What requests cost the most bytes?**
3. **What blocked first paint or LCP?**
4. **What blocked interactivity?**
## What to measure
| Metric | Good | Needs work | Usually means |
| --- | --- | --- | --- |
| **TTFB** | under **800ms** | over **1.2s** | slow server or cache miss |
| **FCP** | under **1.8s** | over **3s** | content appears late |
| **LCP** | under **2.5s** | over **4s** | hero image, font, CSS, server, or JS delay |
| **CLS** | under **0.1** | over **0.25** | unstable layout |
| **Long task** | under **50ms** | over **100ms** | main thread blocked by JS |
| **JS transfer** | under **250KB** | over **500KB** | too much hydration or client code |
| **Font / media transfer** | context dependent | large above-the-fold assets | fonts, posters, videos, hero images |
## What usually blocks what
- **First paint / FCP** is usually gated by **TTFB**, critical HTML, critical CSS, and above-the-fold fonts/images.
- **LCP** is usually gated by the **largest hero asset**. Common causes: hero image, poster image, custom font, render-blocking CSS, or slow server response.
- **Interactivity** is usually gated by **long tasks**. Common causes: too much JS on startup, hydration, or a large framework chunk.
- **Load event** often stays late because of **non-critical assets** like videos, analytics, background images, and delayed client bundles.
## Quick commands
Create a session and open a page:
\`\`\`bash
playwriter session new
playwriter -s 1 -e 'state.page = context.pages().find((p) => p.url() === "about:blank") ?? (await context.newPage()); await state.page.goto("https://example.com", { waitUntil: "domcontentloaded" })'
\`\`\`
Collect a concise vitals report:
\`\`\`bash
playwriter -s 1 -e "$(cat <<'EOF'
await state.page.evaluate(() => {
const metrics = { paints: {}, lcp: 0, cls: 0 }
globalThis.__pwMetrics = metrics
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
metrics.paints[entry.name] = entry.startTime
}
}).observe({ type: 'paint', buffered: true })
new PerformanceObserver((list) => {
const lastEntry = list.getEntries().at(-1)
if (lastEntry) {
metrics.lcp = lastEntry.startTime
}
}).observe({ type: 'largest-contentful-paint', buffered: true })
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!entry.hadRecentInput) {
metrics.cls += entry.value
}
}
}).observe({ type: 'layout-shift', buffered: true })
})
await state.page.reload({ waitUntil: 'domcontentloaded' })
await waitForPageLoad({ page: state.page, timeout: 10000 })
await state.page.waitForTimeout(3000)
const report = await state.page.evaluate(() => {
const nav = performance.getEntriesByType('navigation')[0]
const metrics = globalThis.__pwMetrics
return {
ttfb: nav?.responseStart || 0,
domContentLoaded: nav?.domContentLoadedEventEnd || 0,
load: nav?.loadEventEnd || 0,
fcp: metrics?.paints['first-contentful-paint'] || 0,
lcp: metrics?.lcp || 0,
cls: metrics?.cls || 0,
}
})
console.log(JSON.stringify(report, null, 2))
EOF
)"
\`\`\`
List the heaviest requests with CDP:
\`\`\`bash
playwriter -s 1 -e "$(cat <<'EOF'
const cdp = await getCDPSession({ page: state.page })
await cdp.send('Network.enable')
await cdp.send('Network.setCacheDisabled', { cacheDisabled: true })
const responses = new Map()
const finished = new Map()
cdp.on('Network.responseReceived', (event) => {
responses.set(event.requestId, {
url: event.response.url,
mimeType: event.response.mimeType,
})
})
cdp.on('Network.loadingFinished', (event) => {
finished.set(event.requestId, event.encodedDataLength)
})
await state.page.reload({ waitUntil: 'domcontentloaded' })
await waitForPageLoad({ page: state.page, timeout: 10000 })
await state.page.waitForTimeout(3000)
const largest = [...responses.entries()]
.map(([requestId, response]) => ({
url: response.url,
mimeType: response.mimeType,
bytes: finished.get(requestId) || 0,
}))
.sort((a, b) => b.bytes - a.bytes)
.slice(0, 10)
console.log(JSON.stringify(largest, null, 2))
EOF
)"
\`\`\`
Check interactivity blockers:
\`\`\`bash
playwriter -s 1 -e "$(cat <<'EOF'
await state.page.evaluate(() => {
globalThis.__pwLongTasks = []
globalThis.__pwEvents = []
new PerformanceObserver((list) => {
globalThis.__pwLongTasks.push(
...list.getEntries().map((entry) => ({ startTime: entry.startTime, duration: entry.duration })),
)
}).observe({ type: 'longtask', buffered: true })
new PerformanceObserver((list) => {
globalThis.__pwEvents.push(
...list.getEntries().map((entry) => ({
name: entry.name,
duration: entry.duration,
interactionId: entry.interactionId,
})),
)
}).observe({ type: 'event', buffered: true, durationThreshold: 16 })
})
await state.page.getByRole('button').first().click()
await state.page.waitForTimeout(1000)
const report = await state.page.evaluate(() => ({
longTasks: globalThis.__pwLongTasks.filter((entry) => entry.duration >= 50),
interactions: globalThis.__pwEvents.filter((entry) => entry.interactionId !== 0),
}))
console.log(JSON.stringify(report, null, 2))
EOF
)"
\`\`\`
## How to read the results
**Fast render, heavy payload**
- If **FCP** and **LCP** are good but total bytes are huge, the page probably **looks fast on desktop** but wastes bandwidth on mobile.
- This often happens with **hero videos**, large poster images, or custom fonts.
**Slow first paint**
- If **TTFB** is high, fix **server latency** or caching first.
- If **TTFB** is fine but **FCP** is slow, inspect critical CSS, fonts, and above-the-fold images.
**Slow interactivity**
- If you see **long tasks over 50ms**, startup JS is the first suspect.
- Look for large client bundles, hydration-heavy UI, and event handlers doing too much work.
**Need deeper CPU answers**
- If vitals and request sizes are not enough, record a **\`.cpuprofile\`** with Playwriter's raw **\`Profiler.*\`** CDP commands and inspect it with **[profano](https://github.com/remorses/profano)**.
- A good place to keep your reusable profiling snippets is your dots repo, for example **\`~/.config/opencode/\`**.
**Good load event is not enough**
- A page can have a decent \`load\` time and still feel slow if **LCP** or **long tasks** are bad.
- Prefer **TTFB + FCP + LCP + CLS + long tasks** over the load event alone.
## Performance checklist
**If TTFB is bad**
- cache HTML closer to users
- reduce origin work before response
- avoid expensive server-side data fetching on the critical route
**If FCP or LCP is bad**
- trim or defer render-blocking CSS
- avoid large above-the-fold fonts and images
- preload only truly critical assets
- compress hero media harder
**If interactivity is bad**
- reduce startup JS
- split large client bundles
- avoid hydrating UI that is not immediately interactive
- move optional widgets behind user action or idle time
- if the culprit is still unclear, switch from vitals to a CPU profile and inspect hot functions with **profano**
**If bytes are bad but vitals look good**
- optimize for slower devices anyway
- background videos are the first thing to cut
- subset fonts and trim non-critical client features
## Examples
\`\`\`ts
${performanceExamples}
\`\`\`
`
writeToDestinations('performance-profiling.md', content)
}
/**
* Removes CLI-related sections from skill.md to create prompt.md for the MCP.
*
* Sections removed:
* - "## CLI Usage" section and all its subsections
*/
function stripCliSectionsFromSkill(skillContent: string): string {
// Parse markdown tokens
const tokens = Lexer.lex(skillContent)
// Filter out CLI Usage section and its subsections
const filteredTokens: Token[] = []
let skipUntilLevel: number | null = null
for (const token of tokens) {
if (token.type === 'heading') {
const heading = token as Tokens.Heading
// Check if we should start skipping (CLI Usage section)
if (heading.depth === 2 && heading.text === 'CLI Usage') {
skipUntilLevel = 2
continue
}
// Check if we should stop skipping (next h2 section)
if (skipUntilLevel !== null && heading.depth <= skipUntilLevel) {
skipUntilLevel = null
}
}
if (skipUntilLevel === null) {
filteredTokens.push(token)
}
}
// Reconstruct markdown from tokens
return (
filteredTokens
.map((token) => {
return token.raw
})
.join('')
.trim() + '\n'
)
}
function buildPromptFromSkill() {
// Read skill.md as source of truth
const skillPath = path.join(playwriterDir, 'src', 'skill.md')
const skillContent = fs.readFileSync(skillPath, 'utf-8')
// Generate prompt.md for MCP (without CLI sections)
const promptContent = stripCliSectionsFromSkill(skillContent)
const distPromptPath = path.join(distDir, 'prompt.md')
fs.writeFileSync(distPromptPath, promptContent, 'utf-8')
console.log('Generated playwriter/dist/prompt.md (from skill.md)')
// Copy full skill.md to website/public/ for hosting at playwriter.dev/SKILL.md
const websitePublicRoot = path.join(playwriterDir, '..', 'website', 'public')
ensureDir(websitePublicRoot)
fs.writeFileSync(path.join(websitePublicRoot, 'SKILL.md'), skillContent, 'utf-8')
console.log('Generated website/public/SKILL.md')
}
/**
* Parses YAML frontmatter from a markdown file.
* Returns { frontmatter, body } where frontmatter is the parsed YAML object.
*/
function parseFrontmatter(content: string): { frontmatter: Record<string, string>; body: string } {
const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/)
if (!match) {
return { frontmatter: {}, body: content }
}
const yamlContent = match[1]
const body = match[2]
// Simple YAML parsing for key: value pairs
const frontmatter: Record<string, string> = {}
for (const line of yamlContent.split('\n')) {
const colonIndex = line.indexOf(':')
if (colonIndex > 0) {
const key = line.slice(0, colonIndex).trim()
const value = line.slice(colonIndex + 1).trim()
frontmatter[key] = value
}
}
return { frontmatter, body }
}
/**
* Builds the Well-Known Skills Discovery structure.
*
* Creates:
* - /.well-known/skills/index.json - discovery endpoint
* - /.well-known/skills/playwriter/SKILL.md - skill file
*
* See: https://agentskills.io/specification
*/
function buildWellKnownSkills() {
const repoRoot = path.join(playwriterDir, '..')
const skillSourcePath = path.join(repoRoot, 'skills', 'playwriter', 'SKILL.md')
const websitePublicRoot = path.join(repoRoot, 'website', 'public')
const wellKnownDir = path.join(websitePublicRoot, '.well-known', 'skills')
const playwriterSkillDir = path.join(wellKnownDir, 'playwriter')
// Read and parse the skill file
const skillContent = fs.readFileSync(skillSourcePath, 'utf-8')
const { frontmatter } = parseFrontmatter(skillContent)
// Ensure directories exist
ensureDir(wellKnownDir)
ensureDir(playwriterSkillDir)
// Copy SKILL.md to well-known location
fs.writeFileSync(path.join(playwriterSkillDir, 'SKILL.md'), skillContent, 'utf-8')
console.log('Generated website/public/.well-known/skills/playwriter/SKILL.md')
// Generate index.json
const indexJson = {
skills: [
{
name: frontmatter.name || 'playwriter',
description: frontmatter.description || '',
files: ['SKILL.md'],
},
],
}
fs.writeFileSync(path.join(wellKnownDir, 'index.json'), JSON.stringify(indexJson, null, 2) + '\n', 'utf-8')
console.log('Generated website/public/.well-known/skills/index.json')
}
// Run all builds
buildDebuggerApi()
buildEditorApi()
buildStylesApi()
buildPerformanceProfiling()
buildPromptFromSkill()
buildWellKnownSkills()
console.log('Resource files generated successfully')