From fecac48b5379ba93492af5b1e8ee0df0b0aadcdc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 25 Oct 2025 21:29:15 +0000 Subject: [PATCH 1/6] Initial plan From 56a1a53a252961f5c92198888a35975de87fbd86 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 25 Oct 2025 21:42:17 +0000 Subject: [PATCH 2/6] Add bundle size tracking scripts and CI workflow Co-authored-by: adamziel <205419+adamziel@users.noreply.github.com> --- .github/workflows/bundle-size-check.yml | 83 ++++++ tools/scripts/analyze-bundle-size.mjs | 261 +++++++++++++++++++ tools/scripts/compare-bundle-size.mjs | 330 ++++++++++++++++++++++++ 3 files changed, 674 insertions(+) create mode 100644 .github/workflows/bundle-size-check.yml create mode 100755 tools/scripts/analyze-bundle-size.mjs create mode 100755 tools/scripts/compare-bundle-size.mjs diff --git a/.github/workflows/bundle-size-check.yml b/.github/workflows/bundle-size-check.yml new file mode 100644 index 0000000000..72e6768e4d --- /dev/null +++ b/.github/workflows/bundle-size-check.yml @@ -0,0 +1,83 @@ +name: Bundle Size Check + +on: + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: read + pull-requests: write + +jobs: + check-bundle-size: + runs-on: ubuntu-latest + steps: + - name: Checkout PR branch + uses: actions/checkout@v4 + with: + submodules: true + + - name: Setup and build current branch + uses: ./.github/actions/prepare-playground + + - name: Build website + run: npx nx build playground-website + + - name: Analyze current bundle size + run: node tools/scripts/analyze-bundle-size.mjs + + - name: Save current report + run: cp bundle-size-report.json bundle-size-report-current.json + + - name: Checkout base branch + uses: actions/checkout@v4 + with: + ref: ${{ github.base_ref }} + submodules: true + clean: false + + - name: Build base branch website + run: | + # Clean previous build + rm -rf dist/packages/playground/wasm-wordpress-net + npx nx build playground-website + + - name: Analyze base bundle size + run: node tools/scripts/analyze-bundle-size.mjs + + - name: Save base report + run: cp bundle-size-report.json bundle-size-report-base.json + + - name: Restore current report + run: cp bundle-size-report-current.json bundle-size-report.json + + - name: Compare bundle sizes + id: compare + run: node tools/scripts/compare-bundle-size.mjs bundle-size-report-base.json bundle-size-report.json + + - name: Find existing comment + uses: peter-evans/find-comment@v3 + id: find-comment + with: + issue-number: ${{ github.event.pull_request.number }} + comment-author: 'github-actions[bot]' + body-includes: '## 📦 Bundle Size Report' + + - name: Create or update comment + if: steps.compare.outputs.should_comment == 'true' + uses: peter-evans/create-or-update-comment@v4 + with: + comment-id: ${{ steps.find-comment.outputs.comment-id }} + issue-number: ${{ github.event.pull_request.number }} + body-path: bundle-size-comment.md + edit-mode: replace + + - name: Upload bundle size reports + uses: actions/upload-artifact@v4 + if: always() + with: + name: bundle-size-reports + path: | + bundle-size-report.json + bundle-size-report-base.json + bundle-size-comment.md diff --git a/tools/scripts/analyze-bundle-size.mjs b/tools/scripts/analyze-bundle-size.mjs new file mode 100755 index 0000000000..af0e2be6ce --- /dev/null +++ b/tools/scripts/analyze-bundle-size.mjs @@ -0,0 +1,261 @@ +#!/usr/bin/env node + +/** + * Analyze bundle size for the Playground website build + * + * This script: + * 1. Scans the dist directory for built assets + * 2. Calculates sizes for assets required for first paint and offline mode + * 3. Outputs a JSON report with detailed size information + */ + +import { readFileSync, readdirSync, statSync, existsSync } from 'fs'; +import { join, relative } from 'path'; +import { createGzip } from 'zlib'; +import { pipeline } from 'stream/promises'; +import { createReadStream, createWriteStream } from 'fs'; +import { tmpdir } from 'os'; + +const DIST_DIR = 'dist/packages/playground/wasm-wordpress-net'; + +/** + * Calculate gzipped size of a file + */ +async function getGzipSize(filePath) { + const tempFile = join(tmpdir(), `temp-${Date.now()}.gz`); + try { + await pipeline( + createReadStream(filePath), + createGzip({ level: 9 }), + createWriteStream(tempFile) + ); + const stats = statSync(tempFile); + return stats.size; + } catch (error) { + console.error(`Error calculating gzip size for ${filePath}:`, error); + return 0; + } +} + +/** + * Get all files recursively from a directory + */ +function getAllFiles(dirPath, arrayOfFiles = []) { + if (!existsSync(dirPath)) { + return arrayOfFiles; + } + + const files = readdirSync(dirPath); + + files.forEach((file) => { + const filePath = join(dirPath, file); + if (statSync(filePath).isDirectory()) { + getAllFiles(filePath, arrayOfFiles); + } else { + arrayOfFiles.push(filePath); + } + }); + + return arrayOfFiles; +} + +/** + * Get file size information + */ +async function getFileInfo(filePath, baseDir) { + const stats = statSync(filePath); + const gzipSize = await getGzipSize(filePath); + const relativePath = relative(baseDir, filePath); + + return { + path: '/' + relativePath.replace(/\\/g, '/'), + size: stats.size, + gzipSize, + }; +} + +/** + * Determine if a file is required for first paint + * First paint requires: HTML, critical CSS, initial JS bundles, service worker + * + * Based on the actual loading sequence: + * 1. index.html loads + * 2. Main app bundle (from src/main) loads + * 3. Critical CSS loads + * 4. Service worker registers + * 5. remote.html loads in an iframe + * 6. Remote app bundle loads + */ +function isFirstPaintAsset(path) { + // Root HTML files are critical + if (path === '/index.html' || path === '/remote.html') { + return true; + } + + // Ignore demos, builder, and WordPress content + if ( + path.startsWith('/demos/') || + path.startsWith('/builder/') || + path.startsWith('/wp-') + ) { + return false; + } + + // Manifest and service worker files + if ( + path.includes('manifest.json') || + path.includes('service-worker') || + path === '/favicon.ico' + ) { + return true; + } + + // Assets directory + if (path.startsWith('/assets/')) { + // Exclude optional chunks (CodeMirror, etc.) + if (path.includes('/optional/')) { + return false; + } + + // Exclude large runtime-loaded assets + if ( + path.match(/\/php_.*\.(wasm|js)$/) || + path.match(/\/wp-.*\.zip$/) || + path.match(/\/sqlite-database-integration-.*\.zip$/) || + path.match(/\/blueprints-.*\.phar$/) + ) { + return false; + } + + // Include core JS and CSS bundles + // These are the chunks that vite creates for the initial load + if (path.match(/\.(js|css)$/)) { + return true; + } + + return false; + } + + // Include root-level CSS and JS files (if any) + if (path.match(/\.(js|css)$/) && path.split('/').length === 2) { + return true; + } + + return false; +} + +/** + * Main analysis function + */ +async function analyzeBundle() { + const baseDir = join(process.cwd(), DIST_DIR); + + if (!existsSync(baseDir)) { + console.error(`Build directory not found: ${baseDir}`); + console.error('Please run the build first: npm run build:website'); + process.exit(1); + } + + console.log('Analyzing bundle size...'); + console.log(`Base directory: ${baseDir}`); + + // Get all files + const allFiles = getAllFiles(baseDir); + console.log(`Found ${allFiles.length} files`); + + // Get file information for all files + const fileInfoPromises = allFiles.map((file) => getFileInfo(file, baseDir)); + const fileInfos = await Promise.all(fileInfoPromises); + + // Load offline mode assets list if it exists + let offlineModeAssets = []; + const offlineModeManifestPath = join( + baseDir, + 'assets-required-for-offline-mode.json' + ); + if (existsSync(offlineModeManifestPath)) { + const manifest = JSON.parse( + readFileSync(offlineModeManifestPath, 'utf-8') + ); + offlineModeAssets = manifest; + } + + // Categorize files + const firstPaintAssets = fileInfos.filter((file) => + isFirstPaintAsset(file.path) + ); + const offlineModeAssetInfos = fileInfos.filter((file) => + offlineModeAssets.includes(file.path) + ); + + // Calculate totals + const calculateTotals = (assets) => { + return assets.reduce( + (acc, file) => { + acc.size += file.size; + acc.gzipSize += file.gzipSize; + return acc; + }, + { size: 0, gzipSize: 0 } + ); + }; + + const firstPaintTotals = calculateTotals(firstPaintAssets); + const offlineModeTotals = calculateTotals(offlineModeAssetInfos); + + // Sort files by gzipped size (largest first) + const sortedFirstPaint = [...firstPaintAssets].sort( + (a, b) => b.gzipSize - a.gzipSize + ); + const sortedOfflineMode = [...offlineModeAssetInfos].sort( + (a, b) => b.gzipSize - a.gzipSize + ); + + // Generate report + const report = { + timestamp: new Date().toISOString(), + firstPaint: { + totalSize: firstPaintTotals.size, + totalGzipSize: firstPaintTotals.gzipSize, + fileCount: firstPaintAssets.length, + largestFiles: sortedFirstPaint.slice(0, 10), + allFiles: firstPaintAssets, + }, + offlineMode: { + totalSize: offlineModeTotals.size, + totalGzipSize: offlineModeTotals.gzipSize, + fileCount: offlineModeAssetInfos.length, + largestFiles: sortedOfflineMode.slice(0, 10), + allFiles: offlineModeAssetInfos, + }, + }; + + // Output report + console.log('\n=== Bundle Size Report ===\n'); + console.log('First Paint Assets:'); + console.log( + ` Total: ${(firstPaintTotals.gzipSize / 1024).toFixed(2)} KB (gzipped)` + ); + console.log(` Files: ${firstPaintAssets.length}`); + console.log('\nOffline Mode Assets:'); + console.log( + ` Total: ${(offlineModeTotals.gzipSize / 1024).toFixed( + 2 + )} KB (gzipped)` + ); + console.log(` Files: ${offlineModeAssetInfos.length}`); + + // Write report to file + const reportPath = join(process.cwd(), 'bundle-size-report.json'); + const fs = await import('fs/promises'); + await fs.writeFile(reportPath, JSON.stringify(report, null, 2)); + console.log(`\nReport written to: ${reportPath}`); + + return report; +} + +// Run the analysis +analyzeBundle().catch((error) => { + console.error('Error analyzing bundle:', error); + process.exit(1); +}); diff --git a/tools/scripts/compare-bundle-size.mjs b/tools/scripts/compare-bundle-size.mjs new file mode 100755 index 0000000000..a0dbaacee6 --- /dev/null +++ b/tools/scripts/compare-bundle-size.mjs @@ -0,0 +1,330 @@ +#!/usr/bin/env node + +/** + * Compare bundle sizes between current and base branch + * + * This script: + * 1. Loads bundle size reports from both branches + * 2. Calculates differences + * 3. Generates a markdown report for GitHub PR comments + */ + +import { readFileSync, existsSync } from 'fs'; +import { join } from 'path'; + +const THRESHOLD_KB = 50; // Threshold for posting a comment + +/** + * Format bytes to human readable format + */ +function formatBytes(bytes) { + return `${(bytes / 1024).toFixed(2)} KB`; +} + +/** + * Format size delta with color indicator + */ +function formatDelta(delta) { + if (delta === 0) return '0 KB'; + const sign = delta > 0 ? '+' : ''; + return `${sign}${(delta / 1024).toFixed(2)} KB`; +} + +/** + * Load a bundle report + */ +function loadReport(path) { + if (!existsSync(path)) { + return null; + } + return JSON.parse(readFileSync(path, 'utf-8')); +} + +/** + * Compare two reports and generate markdown + */ +function compareReports(baseReport, currentReport) { + if (!baseReport) { + return { + shouldComment: true, + markdown: generateNewBuildReport(currentReport), + }; + } + + // Calculate deltas + const firstPaintDelta = + currentReport.firstPaint.totalGzipSize - + baseReport.firstPaint.totalGzipSize; + const offlineModeDelta = + currentReport.offlineMode.totalGzipSize - + baseReport.offlineMode.totalGzipSize; + + // Determine if we should post a comment + const firstPaintThresholdExceeded = + Math.abs(firstPaintDelta) >= THRESHOLD_KB * 1024; + const offlineModeThresholdExceeded = + Math.abs(offlineModeDelta) >= THRESHOLD_KB * 1024; + const shouldComment = + firstPaintThresholdExceeded || offlineModeThresholdExceeded; + + // Generate markdown + const markdown = generateComparisonReport( + baseReport, + currentReport, + firstPaintDelta, + offlineModeDelta + ); + + return { + shouldComment, + markdown, + firstPaintDelta, + offlineModeDelta, + }; +} + +/** + * Generate a report for a new build (no base to compare against) + */ +function generateNewBuildReport(report) { + return `## 📦 Bundle Size Report + +### Assets Required for First Paint +- **Total Size**: ${formatBytes(report.firstPaint.totalGzipSize)} (gzipped) +- **File Count**: ${report.firstPaint.fileCount} + +#### Top 10 Largest Files +${generateFileTable(report.firstPaint.largestFiles)} + +### Assets Required for Offline Mode +- **Total Size**: ${formatBytes(report.offlineMode.totalGzipSize)} (gzipped) +- **File Count**: ${report.offlineMode.fileCount} + +#### Top 10 Largest Files +${generateFileTable(report.offlineMode.largestFiles)} +`; +} + +/** + * Generate a comparison report + */ +function generateComparisonReport( + baseReport, + currentReport, + firstPaintDelta, + offlineModeDelta +) { + const firstPaintEmoji = + firstPaintDelta > 0 ? '📈' : firstPaintDelta < 0 ? '📉' : '➡️'; + const offlineModeEmoji = + offlineModeDelta > 0 ? '📈' : offlineModeDelta < 0 ? '📉' : '➡️'; + + let markdown = `## 📦 Bundle Size Report + +`; + + // First Paint Section + markdown += `### ${firstPaintEmoji} Assets Required for First Paint +- **Current Size**: ${formatBytes( + currentReport.firstPaint.totalGzipSize + )} (gzipped) +- **Base Size**: ${formatBytes(baseReport.firstPaint.totalGzipSize)} (gzipped) +- **Delta**: ${formatDelta(firstPaintDelta)} +- **File Count**: ${currentReport.firstPaint.fileCount} (was ${ + baseReport.firstPaint.fileCount + }) + +`; + + // Add file comparison for first paint + const firstPaintFileDeltas = calculateFileDeltas( + baseReport.firstPaint.allFiles, + currentReport.firstPaint.allFiles + ); + + if (firstPaintFileDeltas.length > 0) { + markdown += `#### Files with Largest Changes\n`; + markdown += generateDeltaTable(firstPaintFileDeltas.slice(0, 10)); + markdown += '\n'; + } + + markdown += `#### Top 10 Largest Files\n`; + markdown += generateFileTable(currentReport.firstPaint.largestFiles); + markdown += '\n'; + + // Offline Mode Section + markdown += `### ${offlineModeEmoji} Assets Required for Offline Mode +- **Current Size**: ${formatBytes( + currentReport.offlineMode.totalGzipSize + )} (gzipped) +- **Base Size**: ${formatBytes(baseReport.offlineMode.totalGzipSize)} (gzipped) +- **Delta**: ${formatDelta(offlineModeDelta)} +- **File Count**: ${currentReport.offlineMode.fileCount} (was ${ + baseReport.offlineMode.fileCount + }) + +`; + + // Add file comparison for offline mode + const offlineModeFileDeltas = calculateFileDeltas( + baseReport.offlineMode.allFiles, + currentReport.offlineMode.allFiles + ); + + if (offlineModeFileDeltas.length > 0) { + markdown += `#### Files with Largest Changes\n`; + markdown += generateDeltaTable(offlineModeFileDeltas.slice(0, 10)); + markdown += '\n'; + } + + markdown += `#### Top 10 Largest Files\n`; + markdown += generateFileTable(currentReport.offlineMode.largestFiles); + + return markdown; +} + +/** + * Calculate file-level deltas + */ +function calculateFileDeltas(baseFiles, currentFiles) { + const baseMap = new Map(baseFiles.map((f) => [f.path, f])); + const currentMap = new Map(currentFiles.map((f) => [f.path, f])); + + const deltas = []; + + // Check for modified and new files + for (const [path, currentFile] of currentMap) { + const baseFile = baseMap.get(path); + if (baseFile) { + const delta = currentFile.gzipSize - baseFile.gzipSize; + if (delta !== 0) { + deltas.push({ + path, + delta, + currentSize: currentFile.gzipSize, + baseSize: baseFile.gzipSize, + status: 'modified', + }); + } + } else { + deltas.push({ + path, + delta: currentFile.gzipSize, + currentSize: currentFile.gzipSize, + baseSize: 0, + status: 'added', + }); + } + } + + // Check for removed files + for (const [path, baseFile] of baseMap) { + if (!currentMap.has(path)) { + deltas.push({ + path, + delta: -baseFile.gzipSize, + currentSize: 0, + baseSize: baseFile.gzipSize, + status: 'removed', + }); + } + } + + // Sort by absolute delta (largest changes first) + return deltas.sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta)); +} + +/** + * Generate a markdown table for files + */ +function generateFileTable(files) { + let table = '| File | Size (gzipped) |\n'; + table += '|------|---------------:|\n'; + + for (const file of files) { + table += `| \`${file.path}\` | ${formatBytes(file.gzipSize)} |\n`; + } + + return table; +} + +/** + * Generate a markdown table for file deltas + */ +function generateDeltaTable(deltas) { + let table = '| File | Delta | Current | Previous | Status |\n'; + table += '|------|------:|--------:|---------:|:------:|\n'; + + for (const delta of deltas) { + const statusEmoji = + delta.status === 'added' + ? '🆕' + : delta.status === 'removed' + ? '🗑️' + : delta.delta > 0 + ? '📈' + : '📉'; + + table += `| \`${delta.path}\` | ${formatDelta( + delta.delta + )} | ${formatBytes(delta.currentSize)} | ${formatBytes( + delta.baseSize + )} | ${statusEmoji} |\n`; + } + + return table; +} + +/** + * Main comparison function + */ +async function main() { + const baseReportPath = process.argv[2] || 'bundle-size-report-base.json'; + const currentReportPath = process.argv[3] || 'bundle-size-report.json'; + + console.log('Comparing bundle sizes...'); + console.log(`Base report: ${baseReportPath}`); + console.log(`Current report: ${currentReportPath}`); + + const baseReport = loadReport(baseReportPath); + const currentReport = loadReport(currentReportPath); + + if (!currentReport) { + console.error(`Current report not found: ${currentReportPath}`); + process.exit(1); + } + + const comparison = compareReports(baseReport, currentReport); + + console.log('\n' + comparison.markdown); + + // Write markdown to file for GitHub Actions + const fs = await import('fs/promises'); + await fs.writeFile('bundle-size-comment.md', comparison.markdown); + + // Output results for GitHub Actions + console.log('\n=== GitHub Actions Output ==='); + console.log(`SHOULD_COMMENT=${comparison.shouldComment}`); + if (comparison.firstPaintDelta !== undefined) { + console.log(`FIRST_PAINT_DELTA=${comparison.firstPaintDelta}`); + } + if (comparison.offlineModeDelta !== undefined) { + console.log(`OFFLINE_MODE_DELTA=${comparison.offlineModeDelta}`); + } + + // Set GitHub Actions output + if (process.env.GITHUB_OUTPUT) { + const output = [ + `should_comment=${comparison.shouldComment}`, + `first_paint_delta=${comparison.firstPaintDelta || 0}`, + `offline_mode_delta=${comparison.offlineModeDelta || 0}`, + ].join('\n'); + + await fs.appendFile(process.env.GITHUB_OUTPUT, output + '\n'); + } +} + +main().catch((error) => { + console.error('Error comparing bundles:', error); + process.exit(1); +}); From 0416ecf3fea281d8f6444637fd0e9fffd7097d06 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 25 Oct 2025 21:46:40 +0000 Subject: [PATCH 3/6] Update bundle size check workflow to properly setup base branch Co-authored-by: adamziel <205419+adamziel@users.noreply.github.com> --- .github/workflows/bundle-size-check.yml | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/workflows/bundle-size-check.yml b/.github/workflows/bundle-size-check.yml index 72e6768e4d..293647ea69 100644 --- a/.github/workflows/bundle-size-check.yml +++ b/.github/workflows/bundle-size-check.yml @@ -34,13 +34,12 @@ jobs: with: ref: ${{ github.base_ref }} submodules: true - clean: false + + - name: Setup and build base branch + uses: ./.github/actions/prepare-playground - name: Build base branch website - run: | - # Clean previous build - rm -rf dist/packages/playground/wasm-wordpress-net - npx nx build playground-website + run: npx nx build playground-website - name: Analyze base bundle size run: node tools/scripts/analyze-bundle-size.mjs From e6fb3d37da8bdc0be41e24eb5edb63930bdc607b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 25 Oct 2025 21:47:43 +0000 Subject: [PATCH 4/6] Add documentation for bundle size tracking Co-authored-by: adamziel <205419+adamziel@users.noreply.github.com> --- tools/scripts/README.md | 155 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 tools/scripts/README.md diff --git a/tools/scripts/README.md b/tools/scripts/README.md new file mode 100644 index 0000000000..366cb82c69 --- /dev/null +++ b/tools/scripts/README.md @@ -0,0 +1,155 @@ +# Bundle Size Tracking + +This directory contains scripts for tracking and reporting bundle size changes in WordPress Playground. + +## Overview + +The bundle size tracking system helps ensure that changes to the codebase don't significantly increase the download size required for: + +1. **First Paint**: Assets needed to display the initial WordPress Playground interface +2. **Offline Mode**: Assets cached for offline functionality + +## Scripts + +### `analyze-bundle-size.mjs` + +Analyzes the build output and generates a detailed report of asset sizes. + +**Usage:** + +```bash +npm run build:website +node tools/scripts/analyze-bundle-size.mjs +``` + +**Output:** + +- `bundle-size-report.json`: Detailed JSON report with size information for all assets + +**What it measures:** + +- Total size and gzipped size for first paint assets +- Total size and gzipped size for offline mode assets +- Individual file sizes +- Top 10 largest files in each category + +### `compare-bundle-size.mjs` + +Compares two bundle size reports and generates a markdown report suitable for GitHub PR comments. + +**Usage:** + +```bash +node tools/scripts/compare-bundle-size.mjs [base-report] [current-report] +``` + +**Default paths:** + +- `base-report`: `bundle-size-report-base.json` +- `current-report`: `bundle-size-report.json` + +**Output:** + +- `bundle-size-comment.md`: Markdown-formatted comparison report +- GitHub Actions outputs for workflow automation + +## CI Workflow + +The bundle size check runs automatically on pull requests via the `.github/workflows/bundle-size-check.yml` workflow. + +### How it works + +1. **Build Current Branch**: Builds the website from the PR branch and analyzes the bundle size +2. **Build Base Branch**: Checks out and builds the base branch (usually `trunk`) and analyzes its bundle size +3. **Compare**: Generates a comparison report showing size changes +4. **Comment**: If size changes exceed 50 KB (gzipped) in either category, posts a comment on the PR + +### Comment Threshold + +A PR comment is posted when: + +- First paint assets change by more than ±50 KB (gzipped), OR +- Offline mode assets change by more than ±50 KB (gzipped) + +### Comment Format + +The PR comment includes: + +- **Size Comparison**: Current vs. base size with delta +- **File Count**: Number of files in each category +- **Files with Largest Changes**: Top 10 files with the biggest size deltas +- **Top 10 Largest Files**: Current largest files in each category +- **Status Indicators**: + - 🆕 New file + - 🗑️ Removed file + - 📈 Size increased + - 📉 Size decreased + - ➡️ No change + +## First Paint Assets + +Files considered critical for the first paint include: + +- `index.html` and `remote.html` +- Core JavaScript bundles in `/assets/` (excluding optional chunks) +- Core CSS files +- Service worker +- Manifest files + +**Excluded from first paint:** + +- Optional chunks (e.g., CodeMirror extensions in `/assets/optional/`) +- PHP WASM files (loaded on demand) +- WordPress build ZIPs (loaded on demand) +- SQLite integration (loaded on demand) +- Demos and builder assets + +## Offline Mode Assets + +Files required for offline functionality are determined by the `assets-required-for-offline-mode.json` manifest, which is automatically generated during the build process by the `listAssetsRequiredForOfflineMode` Vite plugin. + +See `packages/vite-extensions/vite-list-assets-required-for-offline-mode.ts` for details on how this manifest is generated. + +## Local Development + +To test bundle size changes locally: + +```bash +# Build the website +npm run build:website + +# Analyze current build +node tools/scripts/analyze-bundle-size.mjs + +# Save as base for comparison +cp bundle-size-report.json bundle-size-report-base.json + +# Make your changes... + +# Build again +npm run build:website + +# Analyze new build +node tools/scripts/analyze-bundle-size.mjs + +# Compare +node tools/scripts/compare-bundle-size.mjs +``` + +## Optimization Tips + +If your PR triggers a bundle size increase: + +1. **Check for new dependencies**: Large libraries can significantly increase bundle size +2. **Use code splitting**: Move non-critical code to lazy-loaded chunks +3. **Optimize assets**: Compress images, minify code +4. **Review bundle composition**: Use tools like `vite-bundle-visualizer` to understand what's taking up space +5. **Consider alternatives**: Look for lighter-weight alternatives to heavy dependencies + +## Artifacts + +The workflow uploads the following artifacts for debugging: + +- `bundle-size-report.json`: Current branch analysis +- `bundle-size-report-base.json`: Base branch analysis +- `bundle-size-comment.md`: Generated PR comment From a03b8f616ef6c560ec3225c2b281fb52111902e8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 25 Oct 2025 23:28:38 +0000 Subject: [PATCH 5/6] Switch to browser-based bundle size measurement using Playwright Co-authored-by: adamziel <205419+adamziel@users.noreply.github.com> --- .github/workflows/bundle-size-check.yml | 29 +- tools/scripts/README.md | 178 +++++---- tools/scripts/analyze-bundle-size.mjs | 261 -------------- tools/scripts/compare-bundle-size.mjs | 284 +++++---------- tools/scripts/measure-bundle-size-browser.mjs | 341 ++++++++++++++++++ 5 files changed, 573 insertions(+), 520 deletions(-) delete mode 100755 tools/scripts/analyze-bundle-size.mjs create mode 100755 tools/scripts/measure-bundle-size-browser.mjs diff --git a/.github/workflows/bundle-size-check.yml b/.github/workflows/bundle-size-check.yml index 293647ea69..99440b42aa 100644 --- a/.github/workflows/bundle-size-check.yml +++ b/.github/workflows/bundle-size-check.yml @@ -23,8 +23,20 @@ jobs: - name: Build website run: npx nx build playground-website - - name: Analyze current bundle size - run: node tools/scripts/analyze-bundle-size.mjs + - name: Start preview server + run: | + npx nx preview playground-website & + # Wait for server to be ready + timeout 60 bash -c 'until curl -s http://localhost:5400 > /dev/null; do sleep 1; done' + + - name: Install Playwright browsers + run: npx playwright install chromium --with-deps + + - name: Measure current bundle size + run: node tools/scripts/measure-bundle-size-browser.mjs + + - name: Stop preview server + run: pkill -f "nx preview" || true - name: Save current report run: cp bundle-size-report.json bundle-size-report-current.json @@ -41,8 +53,17 @@ jobs: - name: Build base branch website run: npx nx build playground-website - - name: Analyze base bundle size - run: node tools/scripts/analyze-bundle-size.mjs + - name: Start preview server for base branch + run: | + npx nx preview playground-website & + # Wait for server to be ready + timeout 60 bash -c 'until curl -s http://localhost:5400 > /dev/null; do sleep 1; done' + + - name: Measure base bundle size + run: node tools/scripts/measure-bundle-size-browser.mjs + + - name: Stop preview server + run: pkill -f "nx preview" || true - name: Save base report run: cp bundle-size-report.json bundle-size-report-base.json diff --git a/tools/scripts/README.md b/tools/scripts/README.md index 366cb82c69..7be3c05294 100644 --- a/tools/scripts/README.md +++ b/tools/scripts/README.md @@ -1,57 +1,58 @@ # Bundle Size Tracking -This directory contains scripts for tracking and reporting bundle size changes in WordPress Playground. +This directory contains scripts for tracking and reporting bundle size changes in WordPress Playground using real browser measurements. ## Overview -The bundle size tracking system helps ensure that changes to the codebase don't significantly increase the download size required for: +The bundle size tracking system uses Playwright to measure actual download sizes at key stages during page load: -1. **First Paint**: Assets needed to display the initial WordPress Playground interface -2. **Offline Mode**: Assets cached for offline functionality +1. **First Paint**: Assets downloaded until the progress bar is visible +2. **WordPress Loaded**: Assets downloaded until WordPress site is ready (nested iframes loaded) +3. **Offline Mode Ready**: All assets downloaded after network activity settles + +This approach provides real-world measurements instead of static file analysis. ## Scripts -### `analyze-bundle-size.mjs` +### `measure-bundle-size-browser.mjs` -Analyzes the build output and generates a detailed report of asset sizes. +Uses Playwright to measure bundle size by monitoring actual browser network requests. **Usage:** - ```bash -npm run build:website -node tools/scripts/analyze-bundle-size.mjs -``` +# Start the development server +npm run dev -**Output:** - -- `bundle-size-report.json`: Detailed JSON report with size information for all assets +# In another terminal, run the measurement +node tools/scripts/measure-bundle-size-browser.mjs +``` **What it measures:** +- Total bytes transferred at each stage +- Number of files loaded +- Time to each milestone +- Top 10 largest files at each stage +- Breakdown by resource type (script, stylesheet, image, etc.) -- Total size and gzipped size for first paint assets -- Total size and gzipped size for offline mode assets -- Individual file sizes -- Top 10 largest files in each category +**Output:** +- `bundle-size-report.json`: Detailed JSON report with measurements ### `compare-bundle-size.mjs` Compares two bundle size reports and generates a markdown report suitable for GitHub PR comments. **Usage:** - ```bash node tools/scripts/compare-bundle-size.mjs [base-report] [current-report] ``` **Default paths:** - -- `base-report`: `bundle-size-report-base.json` -- `current-report`: `bundle-size-report.json` +- `base-report`: `bundle-size-report-base.json` +- `current-report`: `bundle-size-report.json` **Output:** - -- `bundle-size-comment.md`: Markdown-formatted comparison report -- GitHub Actions outputs for workflow automation +- `bundle-size-comment.md`: Markdown-formatted comparison report +- GitHub Actions outputs for workflow automation ## CI Workflow @@ -59,78 +60,102 @@ The bundle size check runs automatically on pull requests via the `.github/workf ### How it works -1. **Build Current Branch**: Builds the website from the PR branch and analyzes the bundle size -2. **Build Base Branch**: Checks out and builds the base branch (usually `trunk`) and analyzes its bundle size -3. **Compare**: Generates a comparison report showing size changes -4. **Comment**: If size changes exceed 50 KB (gzipped) in either category, posts a comment on the PR +1. **Build & Start Current Branch**: + - Builds the website from the PR branch + - Starts the preview server + - Installs Playwright + - Measures bundle size with real browser -### Comment Threshold +2. **Build & Start Base Branch**: + - Checks out and builds the base branch (usually `trunk`) + - Starts the preview server + - Measures its bundle size with real browser -A PR comment is posted when: +3. **Compare**: + - Generates a comparison report showing size changes at each stage + - Includes time delta as well as size delta -- First paint assets change by more than ±50 KB (gzipped), OR -- Offline mode assets change by more than ±50 KB (gzipped) +4. **Comment**: + - If any stage changes by more than 50 KB, posts a comment on the PR + +### Comment Threshold + +A PR comment is posted when any of these change by more than ±50 KB: +- First paint downloads +- WordPress loaded downloads +- Offline mode ready downloads ### Comment Format -The PR comment includes: +The PR comment includes three sections: + +#### 🎨 First Paint (Progress Bar Visible) +- Current vs. base size and load time +- Delta in bytes and time +- Top 10 largest files + +#### ✅ WordPress Loaded (Site Ready) +- Current vs. base size and load time +- Delta in bytes and time +- Top 10 largest files + +#### 💾 Offline Mode Ready (All Downloads Settled) +- Current vs. base size and load time +- Delta in bytes and time +- Top 10 largest files -- **Size Comparison**: Current vs. base size with delta -- **File Count**: Number of files in each category -- **Files with Largest Changes**: Top 10 files with the biggest size deltas -- **Top 10 Largest Files**: Current largest files in each category -- **Status Indicators**: - - 🆕 New file - - 🗑️ Removed file - - 📈 Size increased - - 📉 Size decreased - - ➡️ No change +**Status Indicators**: +- 📈 Size increased +- 📉 Size decreased +- ➡️ No change -## First Paint Assets +## Measurement Stages -Files considered critical for the first paint include: +### First Paint (Progress Bar Visible) -- `index.html` and `remote.html` -- Core JavaScript bundles in `/assets/` (excluding optional chunks) -- Core CSS files -- Service worker -- Manifest files +Measures all downloads until the progress bar becomes visible. This represents the minimum assets needed for users to see that the page is loading. -**Excluded from first paint:** +**Key signals:** +- Progress bar element visible +- Falls back to DOMContentLoaded if no progress bar found -- Optional chunks (e.g., CodeMirror extensions in `/assets/optional/`) -- PHP WASM files (loaded on demand) -- WordPress build ZIPs (loaded on demand) -- SQLite integration (loaded on demand) -- Demos and builder assets +### WordPress Loaded (Site Ready) -## Offline Mode Assets +Measures all downloads until WordPress is fully loaded in the nested iframe, indicating the site is interactive and ready to use. -Files required for offline functionality are determined by the `assets-required-for-offline-mode.json` manifest, which is automatically generated during the build process by the `listAssetsRequiredForOfflineMode` Vite plugin. +**Key signals:** +- WordPress iframe body element is attached +- Falls back to window load event if iframe not found -See `packages/vite-extensions/vite-list-assets-required-for-offline-mode.ts` for details on how this manifest is generated. +### Offline Mode Ready (All Downloads Settled) + +Measures all downloads after network activity settles (5 seconds of no new requests). This represents all assets that would be cached for offline use. + +**Key signals:** +- No network requests for 5 consecutive seconds +- Includes all lazy-loaded assets ## Local Development To test bundle size changes locally: ```bash -# Build the website -npm run build:website +# Terminal 1: Start the dev server +npm run dev -# Analyze current build -node tools/scripts/analyze-bundle-size.mjs +# Terminal 2: Measure current build +node tools/scripts/measure-bundle-size-browser.mjs # Save as base for comparison cp bundle-size-report.json bundle-size-report-base.json # Make your changes... -# Build again -npm run build:website +# Restart dev server if needed +npm run dev -# Analyze new build -node tools/scripts/analyze-bundle-size.mjs +# Measure new build +node tools/scripts/measure-bundle-size-browser.mjs # Compare node tools/scripts/compare-bundle-size.mjs @@ -143,13 +168,24 @@ If your PR triggers a bundle size increase: 1. **Check for new dependencies**: Large libraries can significantly increase bundle size 2. **Use code splitting**: Move non-critical code to lazy-loaded chunks 3. **Optimize assets**: Compress images, minify code -4. **Review bundle composition**: Use tools like `vite-bundle-visualizer` to understand what's taking up space +4. **Review network tab**: Use browser DevTools to see what's being loaded 5. **Consider alternatives**: Look for lighter-weight alternatives to heavy dependencies +6. **Analyze resource types**: Check if images, scripts, or styles are the main contributor + +## Browser-Based Measurement Benefits + +Using real browser measurements instead of static file analysis provides: + +- **Realistic data**: Measures what users actually download +- **Network behavior**: Captures caching, compression, and HTTP/2 multiplexing effects +- **Load timing**: Shows when assets are downloaded relative to page milestones +- **Resource prioritization**: Reflects browser's actual loading strategy +- **Accurate offline assets**: Measures what's actually cached, not estimates ## Artifacts The workflow uploads the following artifacts for debugging: -- `bundle-size-report.json`: Current branch analysis -- `bundle-size-report-base.json`: Base branch analysis -- `bundle-size-comment.md`: Generated PR comment +- `bundle-size-report.json`: Current branch measurements +- `bundle-size-report-base.json`: Base branch measurements +- `bundle-size-comment.md`: Generated PR comment diff --git a/tools/scripts/analyze-bundle-size.mjs b/tools/scripts/analyze-bundle-size.mjs deleted file mode 100755 index af0e2be6ce..0000000000 --- a/tools/scripts/analyze-bundle-size.mjs +++ /dev/null @@ -1,261 +0,0 @@ -#!/usr/bin/env node - -/** - * Analyze bundle size for the Playground website build - * - * This script: - * 1. Scans the dist directory for built assets - * 2. Calculates sizes for assets required for first paint and offline mode - * 3. Outputs a JSON report with detailed size information - */ - -import { readFileSync, readdirSync, statSync, existsSync } from 'fs'; -import { join, relative } from 'path'; -import { createGzip } from 'zlib'; -import { pipeline } from 'stream/promises'; -import { createReadStream, createWriteStream } from 'fs'; -import { tmpdir } from 'os'; - -const DIST_DIR = 'dist/packages/playground/wasm-wordpress-net'; - -/** - * Calculate gzipped size of a file - */ -async function getGzipSize(filePath) { - const tempFile = join(tmpdir(), `temp-${Date.now()}.gz`); - try { - await pipeline( - createReadStream(filePath), - createGzip({ level: 9 }), - createWriteStream(tempFile) - ); - const stats = statSync(tempFile); - return stats.size; - } catch (error) { - console.error(`Error calculating gzip size for ${filePath}:`, error); - return 0; - } -} - -/** - * Get all files recursively from a directory - */ -function getAllFiles(dirPath, arrayOfFiles = []) { - if (!existsSync(dirPath)) { - return arrayOfFiles; - } - - const files = readdirSync(dirPath); - - files.forEach((file) => { - const filePath = join(dirPath, file); - if (statSync(filePath).isDirectory()) { - getAllFiles(filePath, arrayOfFiles); - } else { - arrayOfFiles.push(filePath); - } - }); - - return arrayOfFiles; -} - -/** - * Get file size information - */ -async function getFileInfo(filePath, baseDir) { - const stats = statSync(filePath); - const gzipSize = await getGzipSize(filePath); - const relativePath = relative(baseDir, filePath); - - return { - path: '/' + relativePath.replace(/\\/g, '/'), - size: stats.size, - gzipSize, - }; -} - -/** - * Determine if a file is required for first paint - * First paint requires: HTML, critical CSS, initial JS bundles, service worker - * - * Based on the actual loading sequence: - * 1. index.html loads - * 2. Main app bundle (from src/main) loads - * 3. Critical CSS loads - * 4. Service worker registers - * 5. remote.html loads in an iframe - * 6. Remote app bundle loads - */ -function isFirstPaintAsset(path) { - // Root HTML files are critical - if (path === '/index.html' || path === '/remote.html') { - return true; - } - - // Ignore demos, builder, and WordPress content - if ( - path.startsWith('/demos/') || - path.startsWith('/builder/') || - path.startsWith('/wp-') - ) { - return false; - } - - // Manifest and service worker files - if ( - path.includes('manifest.json') || - path.includes('service-worker') || - path === '/favicon.ico' - ) { - return true; - } - - // Assets directory - if (path.startsWith('/assets/')) { - // Exclude optional chunks (CodeMirror, etc.) - if (path.includes('/optional/')) { - return false; - } - - // Exclude large runtime-loaded assets - if ( - path.match(/\/php_.*\.(wasm|js)$/) || - path.match(/\/wp-.*\.zip$/) || - path.match(/\/sqlite-database-integration-.*\.zip$/) || - path.match(/\/blueprints-.*\.phar$/) - ) { - return false; - } - - // Include core JS and CSS bundles - // These are the chunks that vite creates for the initial load - if (path.match(/\.(js|css)$/)) { - return true; - } - - return false; - } - - // Include root-level CSS and JS files (if any) - if (path.match(/\.(js|css)$/) && path.split('/').length === 2) { - return true; - } - - return false; -} - -/** - * Main analysis function - */ -async function analyzeBundle() { - const baseDir = join(process.cwd(), DIST_DIR); - - if (!existsSync(baseDir)) { - console.error(`Build directory not found: ${baseDir}`); - console.error('Please run the build first: npm run build:website'); - process.exit(1); - } - - console.log('Analyzing bundle size...'); - console.log(`Base directory: ${baseDir}`); - - // Get all files - const allFiles = getAllFiles(baseDir); - console.log(`Found ${allFiles.length} files`); - - // Get file information for all files - const fileInfoPromises = allFiles.map((file) => getFileInfo(file, baseDir)); - const fileInfos = await Promise.all(fileInfoPromises); - - // Load offline mode assets list if it exists - let offlineModeAssets = []; - const offlineModeManifestPath = join( - baseDir, - 'assets-required-for-offline-mode.json' - ); - if (existsSync(offlineModeManifestPath)) { - const manifest = JSON.parse( - readFileSync(offlineModeManifestPath, 'utf-8') - ); - offlineModeAssets = manifest; - } - - // Categorize files - const firstPaintAssets = fileInfos.filter((file) => - isFirstPaintAsset(file.path) - ); - const offlineModeAssetInfos = fileInfos.filter((file) => - offlineModeAssets.includes(file.path) - ); - - // Calculate totals - const calculateTotals = (assets) => { - return assets.reduce( - (acc, file) => { - acc.size += file.size; - acc.gzipSize += file.gzipSize; - return acc; - }, - { size: 0, gzipSize: 0 } - ); - }; - - const firstPaintTotals = calculateTotals(firstPaintAssets); - const offlineModeTotals = calculateTotals(offlineModeAssetInfos); - - // Sort files by gzipped size (largest first) - const sortedFirstPaint = [...firstPaintAssets].sort( - (a, b) => b.gzipSize - a.gzipSize - ); - const sortedOfflineMode = [...offlineModeAssetInfos].sort( - (a, b) => b.gzipSize - a.gzipSize - ); - - // Generate report - const report = { - timestamp: new Date().toISOString(), - firstPaint: { - totalSize: firstPaintTotals.size, - totalGzipSize: firstPaintTotals.gzipSize, - fileCount: firstPaintAssets.length, - largestFiles: sortedFirstPaint.slice(0, 10), - allFiles: firstPaintAssets, - }, - offlineMode: { - totalSize: offlineModeTotals.size, - totalGzipSize: offlineModeTotals.gzipSize, - fileCount: offlineModeAssetInfos.length, - largestFiles: sortedOfflineMode.slice(0, 10), - allFiles: offlineModeAssetInfos, - }, - }; - - // Output report - console.log('\n=== Bundle Size Report ===\n'); - console.log('First Paint Assets:'); - console.log( - ` Total: ${(firstPaintTotals.gzipSize / 1024).toFixed(2)} KB (gzipped)` - ); - console.log(` Files: ${firstPaintAssets.length}`); - console.log('\nOffline Mode Assets:'); - console.log( - ` Total: ${(offlineModeTotals.gzipSize / 1024).toFixed( - 2 - )} KB (gzipped)` - ); - console.log(` Files: ${offlineModeAssetInfos.length}`); - - // Write report to file - const reportPath = join(process.cwd(), 'bundle-size-report.json'); - const fs = await import('fs/promises'); - await fs.writeFile(reportPath, JSON.stringify(report, null, 2)); - console.log(`\nReport written to: ${reportPath}`); - - return report; -} - -// Run the analysis -analyzeBundle().catch((error) => { - console.error('Error analyzing bundle:', error); - process.exit(1); -}); diff --git a/tools/scripts/compare-bundle-size.mjs b/tools/scripts/compare-bundle-size.mjs index a0dbaacee6..9e8f984d74 100755 --- a/tools/scripts/compare-bundle-size.mjs +++ b/tools/scripts/compare-bundle-size.mjs @@ -2,15 +2,12 @@ /** * Compare bundle sizes between current and base branch - * - * This script: - * 1. Loads bundle size reports from both branches - * 2. Calculates differences - * 3. Generates a markdown report for GitHub PR comments + * + * This script compares browser-based measurements from two builds + * and generates a markdown report for GitHub PR comments. */ import { readFileSync, existsSync } from 'fs'; -import { join } from 'path'; const THRESHOLD_KB = 50; // Threshold for posting a comment @@ -18,15 +15,21 @@ const THRESHOLD_KB = 50; // Threshold for posting a comment * Format bytes to human readable format */ function formatBytes(bytes) { + if (bytes >= 1024 * 1024) { + return `${(bytes / 1024 / 1024).toFixed(2)} MB`; + } return `${(bytes / 1024).toFixed(2)} KB`; } /** - * Format size delta with color indicator + * Format size delta with sign */ function formatDelta(delta) { if (delta === 0) return '0 KB'; const sign = delta > 0 ? '+' : ''; + if (Math.abs(delta) >= 1024 * 1024) { + return `${sign}${(delta / 1024 / 1024).toFixed(2)} MB`; + } return `${sign}${(delta / 1024).toFixed(2)} KB`; } @@ -40,6 +43,28 @@ function loadReport(path) { return JSON.parse(readFileSync(path, 'utf-8')); } +/** + * Generate a markdown table for largest files + */ +function generateFileTable(files) { + if (!files || files.length === 0) { + return '_No files tracked_'; + } + + let table = '| File | Size | Type |\n'; + table += '|------|-----:|:----:|\n'; + + for (const file of files) { + const url = new URL(file.url); + const path = url.pathname.length > 60 + ? '...' + url.pathname.slice(-57) + : url.pathname; + table += `| \`${path}\` | ${formatBytes(file.size)} | ${file.resourceType} |\n`; + } + + return table; +} + /** * Compare two reports and generate markdown */ @@ -51,27 +76,26 @@ function compareReports(baseReport, currentReport) { }; } + const base = baseReport.measurements; + const current = currentReport.measurements; + // Calculate deltas - const firstPaintDelta = - currentReport.firstPaint.totalGzipSize - - baseReport.firstPaint.totalGzipSize; - const offlineModeDelta = - currentReport.offlineMode.totalGzipSize - - baseReport.offlineMode.totalGzipSize; - - // Determine if we should post a comment - const firstPaintThresholdExceeded = - Math.abs(firstPaintDelta) >= THRESHOLD_KB * 1024; - const offlineModeThresholdExceeded = - Math.abs(offlineModeDelta) >= THRESHOLD_KB * 1024; + const firstPaintDelta = current.firstPaint.totalBytes - base.firstPaint.totalBytes; + const wpLoadedDelta = current.wordpressLoaded.totalBytes - base.wordpressLoaded.totalBytes; + const offlineModeDelta = current.offlineModeReady.totalBytes - base.offlineModeReady.totalBytes; + + // Determine if we should post a comment (50KB threshold) const shouldComment = - firstPaintThresholdExceeded || offlineModeThresholdExceeded; + Math.abs(firstPaintDelta) >= THRESHOLD_KB * 1024 || + Math.abs(wpLoadedDelta) >= THRESHOLD_KB * 1024 || + Math.abs(offlineModeDelta) >= THRESHOLD_KB * 1024; // Generate markdown const markdown = generateComparisonReport( - baseReport, - currentReport, + base, + current, firstPaintDelta, + wpLoadedDelta, offlineModeDelta ); @@ -79,29 +103,42 @@ function compareReports(baseReport, currentReport) { shouldComment, markdown, firstPaintDelta, + wpLoadedDelta, offlineModeDelta, }; } /** - * Generate a report for a new build (no base to compare against) + * Generate a report for a new build */ function generateNewBuildReport(report) { + const m = report.measurements; + return `## 📦 Bundle Size Report -### Assets Required for First Paint -- **Total Size**: ${formatBytes(report.firstPaint.totalGzipSize)} (gzipped) -- **File Count**: ${report.firstPaint.fileCount} +### 🎨 First Paint (Progress Bar Visible) +- **Total Downloaded**: ${formatBytes(m.firstPaint.totalBytes)} +- **File Count**: ${m.firstPaint.fileCount} +- **Time**: ${m.firstPaint.timestamp}ms + +#### Top 10 Largest Files +${generateFileTable(m.firstPaint.largestFiles)} + +### ✅ WordPress Loaded (Site Ready) +- **Total Downloaded**: ${formatBytes(m.wordpressLoaded.totalBytes)} +- **File Count**: ${m.wordpressLoaded.fileCount} +- **Time**: ${m.wordpressLoaded.timestamp}ms #### Top 10 Largest Files -${generateFileTable(report.firstPaint.largestFiles)} +${generateFileTable(m.wordpressLoaded.largestFiles)} -### Assets Required for Offline Mode -- **Total Size**: ${formatBytes(report.offlineMode.totalGzipSize)} (gzipped) -- **File Count**: ${report.offlineMode.fileCount} +### 💾 Offline Mode Ready (All Downloads Settled) +- **Total Downloaded**: ${formatBytes(m.offlineModeReady.totalBytes)} +- **File Count**: ${m.offlineModeReady.fileCount} +- **Time**: ${m.offlineModeReady.timestamp}ms #### Top 10 Largest Files -${generateFileTable(report.offlineMode.largestFiles)} +${generateFileTable(m.offlineModeReady.largestFiles)} `; } @@ -109,170 +146,45 @@ ${generateFileTable(report.offlineMode.largestFiles)} * Generate a comparison report */ function generateComparisonReport( - baseReport, - currentReport, + base, + current, firstPaintDelta, + wpLoadedDelta, offlineModeDelta ) { - const firstPaintEmoji = - firstPaintDelta > 0 ? '📈' : firstPaintDelta < 0 ? '📉' : '➡️'; - const offlineModeEmoji = - offlineModeDelta > 0 ? '📈' : offlineModeDelta < 0 ? '📉' : '➡️'; + const firstPaintEmoji = firstPaintDelta > 0 ? '📈' : firstPaintDelta < 0 ? '📉' : '➡️'; + const wpLoadedEmoji = wpLoadedDelta > 0 ? '📈' : wpLoadedDelta < 0 ? '📉' : '➡️'; + const offlineModeEmoji = offlineModeDelta > 0 ? '📈' : offlineModeDelta < 0 ? '📉' : '➡️'; - let markdown = `## 📦 Bundle Size Report - -`; + return `## 📦 Bundle Size Report - // First Paint Section - markdown += `### ${firstPaintEmoji} Assets Required for First Paint -- **Current Size**: ${formatBytes( - currentReport.firstPaint.totalGzipSize - )} (gzipped) -- **Base Size**: ${formatBytes(baseReport.firstPaint.totalGzipSize)} (gzipped) -- **Delta**: ${formatDelta(firstPaintDelta)} -- **File Count**: ${currentReport.firstPaint.fileCount} (was ${ - baseReport.firstPaint.fileCount - }) +### ${firstPaintEmoji} First Paint (Progress Bar Visible) +- **Current**: ${formatBytes(current.firstPaint.totalBytes)} in ${current.firstPaint.timestamp}ms +- **Base**: ${formatBytes(base.firstPaint.totalBytes)} in ${base.firstPaint.timestamp}ms +- **Delta**: ${formatDelta(firstPaintDelta)} (${formatDelta(current.firstPaint.timestamp - base.firstPaint.timestamp)} time) +- **Files**: ${current.firstPaint.fileCount} (was ${base.firstPaint.fileCount}) -`; +#### Top 10 Largest Files +${generateFileTable(current.firstPaint.largestFiles)} - // Add file comparison for first paint - const firstPaintFileDeltas = calculateFileDeltas( - baseReport.firstPaint.allFiles, - currentReport.firstPaint.allFiles - ); +### ${wpLoadedEmoji} WordPress Loaded (Site Ready) +- **Current**: ${formatBytes(current.wordpressLoaded.totalBytes)} in ${current.wordpressLoaded.timestamp}ms +- **Base**: ${formatBytes(base.wordpressLoaded.totalBytes)} in ${base.wordpressLoaded.timestamp}ms +- **Delta**: ${formatDelta(wpLoadedDelta)} (${formatDelta(current.wordpressLoaded.timestamp - base.wordpressLoaded.timestamp)} time) +- **Files**: ${current.wordpressLoaded.fileCount} (was ${base.wordpressLoaded.fileCount}) - if (firstPaintFileDeltas.length > 0) { - markdown += `#### Files with Largest Changes\n`; - markdown += generateDeltaTable(firstPaintFileDeltas.slice(0, 10)); - markdown += '\n'; - } +#### Top 10 Largest Files +${generateFileTable(current.wordpressLoaded.largestFiles)} - markdown += `#### Top 10 Largest Files\n`; - markdown += generateFileTable(currentReport.firstPaint.largestFiles); - markdown += '\n'; - - // Offline Mode Section - markdown += `### ${offlineModeEmoji} Assets Required for Offline Mode -- **Current Size**: ${formatBytes( - currentReport.offlineMode.totalGzipSize - )} (gzipped) -- **Base Size**: ${formatBytes(baseReport.offlineMode.totalGzipSize)} (gzipped) -- **Delta**: ${formatDelta(offlineModeDelta)} -- **File Count**: ${currentReport.offlineMode.fileCount} (was ${ - baseReport.offlineMode.fileCount - }) +### ${offlineModeEmoji} Offline Mode Ready (All Downloads Settled) +- **Current**: ${formatBytes(current.offlineModeReady.totalBytes)} in ${current.offlineModeReady.timestamp}ms +- **Base**: ${formatBytes(base.offlineModeReady.totalBytes)} in ${base.offlineModeReady.timestamp}ms +- **Delta**: ${formatDelta(offlineModeDelta)} (${formatDelta(current.offlineModeReady.timestamp - base.offlineModeReady.timestamp)} time) +- **Files**: ${current.offlineModeReady.fileCount} (was ${base.offlineModeReady.fileCount}) +#### Top 10 Largest Files +${generateFileTable(current.offlineModeReady.largestFiles)} `; - - // Add file comparison for offline mode - const offlineModeFileDeltas = calculateFileDeltas( - baseReport.offlineMode.allFiles, - currentReport.offlineMode.allFiles - ); - - if (offlineModeFileDeltas.length > 0) { - markdown += `#### Files with Largest Changes\n`; - markdown += generateDeltaTable(offlineModeFileDeltas.slice(0, 10)); - markdown += '\n'; - } - - markdown += `#### Top 10 Largest Files\n`; - markdown += generateFileTable(currentReport.offlineMode.largestFiles); - - return markdown; -} - -/** - * Calculate file-level deltas - */ -function calculateFileDeltas(baseFiles, currentFiles) { - const baseMap = new Map(baseFiles.map((f) => [f.path, f])); - const currentMap = new Map(currentFiles.map((f) => [f.path, f])); - - const deltas = []; - - // Check for modified and new files - for (const [path, currentFile] of currentMap) { - const baseFile = baseMap.get(path); - if (baseFile) { - const delta = currentFile.gzipSize - baseFile.gzipSize; - if (delta !== 0) { - deltas.push({ - path, - delta, - currentSize: currentFile.gzipSize, - baseSize: baseFile.gzipSize, - status: 'modified', - }); - } - } else { - deltas.push({ - path, - delta: currentFile.gzipSize, - currentSize: currentFile.gzipSize, - baseSize: 0, - status: 'added', - }); - } - } - - // Check for removed files - for (const [path, baseFile] of baseMap) { - if (!currentMap.has(path)) { - deltas.push({ - path, - delta: -baseFile.gzipSize, - currentSize: 0, - baseSize: baseFile.gzipSize, - status: 'removed', - }); - } - } - - // Sort by absolute delta (largest changes first) - return deltas.sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta)); -} - -/** - * Generate a markdown table for files - */ -function generateFileTable(files) { - let table = '| File | Size (gzipped) |\n'; - table += '|------|---------------:|\n'; - - for (const file of files) { - table += `| \`${file.path}\` | ${formatBytes(file.gzipSize)} |\n`; - } - - return table; -} - -/** - * Generate a markdown table for file deltas - */ -function generateDeltaTable(deltas) { - let table = '| File | Delta | Current | Previous | Status |\n'; - table += '|------|------:|--------:|---------:|:------:|\n'; - - for (const delta of deltas) { - const statusEmoji = - delta.status === 'added' - ? '🆕' - : delta.status === 'removed' - ? '🗑️' - : delta.delta > 0 - ? '📈' - : '📉'; - - table += `| \`${delta.path}\` | ${formatDelta( - delta.delta - )} | ${formatBytes(delta.currentSize)} | ${formatBytes( - delta.baseSize - )} | ${statusEmoji} |\n`; - } - - return table; } /** @@ -308,6 +220,9 @@ async function main() { if (comparison.firstPaintDelta !== undefined) { console.log(`FIRST_PAINT_DELTA=${comparison.firstPaintDelta}`); } + if (comparison.wpLoadedDelta !== undefined) { + console.log(`WP_LOADED_DELTA=${comparison.wpLoadedDelta}`); + } if (comparison.offlineModeDelta !== undefined) { console.log(`OFFLINE_MODE_DELTA=${comparison.offlineModeDelta}`); } @@ -317,6 +232,7 @@ async function main() { const output = [ `should_comment=${comparison.shouldComment}`, `first_paint_delta=${comparison.firstPaintDelta || 0}`, + `wp_loaded_delta=${comparison.wpLoadedDelta || 0}`, `offline_mode_delta=${comparison.offlineModeDelta || 0}`, ].join('\n'); diff --git a/tools/scripts/measure-bundle-size-browser.mjs b/tools/scripts/measure-bundle-size-browser.mjs new file mode 100755 index 0000000000..36a8ee551d --- /dev/null +++ b/tools/scripts/measure-bundle-size-browser.mjs @@ -0,0 +1,341 @@ +#!/usr/bin/env node + +/** + * Measure bundle size using actual browser and network monitoring + * + * This script uses Playwright to: + * 1. Launch the playground website + * 2. Track all network requests + * 3. Measure downloads at three key stages: + * - Until progress bar visible (first paint) + * - After WordPress loaded (site ready) + * - After all downloads settle (offline mode readiness) + */ + +import { chromium } from 'playwright'; +import { existsSync } from 'fs'; +import { writeFile } from 'fs/promises'; + +const WEBSITE_URL = 'http://localhost:5400'; +const NETWORK_IDLE_TIMEOUT = 5000; // 5 seconds of no network activity + +/** + * Track network requests and calculate total bytes transferred + */ +class NetworkMonitor { + constructor() { + this.requests = []; + this.responses = new Map(); + this.startTime = null; + } + + /** + * Attach to a page to monitor network activity + */ + attach(page) { + this.startTime = Date.now(); + + page.on('request', (request) => { + this.requests.push({ + url: request.url(), + method: request.method(), + resourceType: request.resourceType(), + timestamp: Date.now() - this.startTime, + }); + }); + + page.on('response', async (response) => { + const request = response.request(); + const url = request.url(); + + try { + const headers = response.headers(); + const contentLength = headers['content-length']; + const body = await response.body().catch(() => null); + + this.responses.set(url, { + url, + status: response.status(), + contentLength: contentLength + ? parseInt(contentLength, 10) + : null, + actualSize: body ? body.length : 0, + resourceType: request.resourceType(), + timestamp: Date.now() - this.startTime, + }); + } catch (error) { + // Some responses can't be read (e.g., service worker) + console.warn(`Could not read response for ${url}:`, error.message); + } + }); + } + + /** + * Get all responses up to a certain timestamp + */ + getResponsesUntil(timestamp) { + return Array.from(this.responses.values()).filter( + (r) => r.timestamp <= timestamp + ); + } + + /** + * Calculate total bytes transferred + */ + calculateTotalBytes(responses) { + return responses.reduce((total, response) => { + // Use actual size if available, fall back to content-length + const size = response.actualSize || response.contentLength || 0; + return total + size; + }, 0); + } + + /** + * Group responses by resource type + */ + groupByResourceType(responses) { + const groups = {}; + for (const response of responses) { + const type = response.resourceType || 'other'; + if (!groups[type]) { + groups[type] = []; + } + groups[type].push(response); + } + return groups; + } + + /** + * Get largest files + */ + getLargestFiles(responses, count = 10) { + return [...responses] + .sort((a, b) => { + const sizeA = a.actualSize || a.contentLength || 0; + const sizeB = b.actualSize || b.contentLength || 0; + return sizeB - sizeA; + }) + .slice(0, count) + .map((r) => ({ + url: r.url, + size: r.actualSize || r.contentLength || 0, + resourceType: r.resourceType, + })); + } +} + +/** + * Wait for network to be idle + */ +async function waitForNetworkIdle(page, timeout = NETWORK_IDLE_TIMEOUT) { + let lastRequestTime = Date.now(); + let requestCount = 0; + + const requestListener = () => { + lastRequestTime = Date.now(); + requestCount++; + }; + + page.on('request', requestListener); + + try { + // Wait for network idle + while (Date.now() - lastRequestTime < timeout) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + } finally { + page.off('request', requestListener); + } + + return requestCount; +} + +/** + * Main measurement function + */ +async function measureBundleSize() { + console.log('Starting browser-based bundle size measurement...'); + + // Check if server is running + try { + const response = await fetch(WEBSITE_URL); + if (!response.ok) { + throw new Error(`Server returned ${response.status}`); + } + } catch (error) { + console.error(`Website not accessible at ${WEBSITE_URL}`); + console.error('Please start the dev server first: npm run dev'); + process.exit(1); + } + + const browser = await chromium.launch({ + headless: true, + }); + + const context = await browser.newContext({ + // Disable cache to get accurate measurements + ignoreHTTPSErrors: true, + }); + + const page = await context.newPage(); + + // Set up network monitoring + const monitor = new NetworkMonitor(); + monitor.attach(page); + + const measurements = {}; + + try { + // Navigate to the website + console.log(`Loading ${WEBSITE_URL}...`); + await page.goto(WEBSITE_URL, { + waitUntil: 'domcontentloaded', + }); + + // Stage 1: Wait for progress bar to be visible (first paint) + console.log('Waiting for progress bar...'); + try { + await page.waitForSelector('.progress-bar, [role="progressbar"]', { + timeout: 10000, + state: 'visible', + }); + const progressBarTime = Date.now() - monitor.startTime; + const progressBarResponses = + monitor.getResponsesUntil(progressBarTime); + + measurements.firstPaint = { + timestamp: progressBarTime, + totalBytes: monitor.calculateTotalBytes(progressBarResponses), + fileCount: progressBarResponses.length, + largestFiles: monitor.getLargestFiles(progressBarResponses), + byType: monitor.groupByResourceType(progressBarResponses), + }; + + console.log( + `Progress bar visible at ${progressBarTime}ms, ${measurements.firstPaint.totalBytes} bytes downloaded` + ); + } catch (error) { + console.warn('Progress bar not found, using DOM content loaded instead'); + const domContentLoadedTime = Date.now() - monitor.startTime; + const responses = monitor.getResponsesUntil(domContentLoadedTime); + + measurements.firstPaint = { + timestamp: domContentLoadedTime, + totalBytes: monitor.calculateTotalBytes(responses), + fileCount: responses.length, + largestFiles: monitor.getLargestFiles(responses), + byType: monitor.groupByResourceType(responses), + }; + } + + // Stage 2: Wait for WordPress to load (nested iframes ready) + console.log('Waiting for WordPress to load...'); + try { + // Wait for the WordPress iframe + const wpFrame = page.frameLocator('#playground-viewport:visible, .playground-viewport:visible').frameLocator('#wp'); + await wpFrame.locator('body').waitFor({ + state: 'attached', + timeout: 30000, + }); + + const wpLoadedTime = Date.now() - monitor.startTime; + const wpLoadedResponses = monitor.getResponsesUntil(wpLoadedTime); + + measurements.wordpressLoaded = { + timestamp: wpLoadedTime, + totalBytes: monitor.calculateTotalBytes(wpLoadedResponses), + fileCount: wpLoadedResponses.length, + largestFiles: monitor.getLargestFiles(wpLoadedResponses), + byType: monitor.groupByResourceType(wpLoadedResponses), + }; + + console.log( + `WordPress loaded at ${wpLoadedTime}ms, ${measurements.wordpressLoaded.totalBytes} bytes downloaded` + ); + } catch (error) { + console.warn('WordPress iframe not found:', error.message); + // Fall back to network load event + await page.waitForLoadState('load'); + const loadTime = Date.now() - monitor.startTime; + const responses = monitor.getResponsesUntil(loadTime); + + measurements.wordpressLoaded = { + timestamp: loadTime, + totalBytes: monitor.calculateTotalBytes(responses), + fileCount: responses.length, + largestFiles: monitor.getLargestFiles(responses), + byType: monitor.groupByResourceType(responses), + }; + } + + // Stage 3: Wait for all downloads to settle (offline mode ready) + console.log('Waiting for network to be idle...'); + await waitForNetworkIdle(page, NETWORK_IDLE_TIMEOUT); + + const networkIdleTime = Date.now() - monitor.startTime; + const allResponses = Array.from(monitor.responses.values()); + + measurements.offlineModeReady = { + timestamp: networkIdleTime, + totalBytes: monitor.calculateTotalBytes(allResponses), + fileCount: allResponses.length, + largestFiles: monitor.getLargestFiles(allResponses), + byType: monitor.groupByResourceType(allResponses), + }; + + console.log( + `Network idle at ${networkIdleTime}ms, ${measurements.offlineModeReady.totalBytes} bytes downloaded` + ); + + // Generate report + const report = { + timestamp: new Date().toISOString(), + url: WEBSITE_URL, + measurements, + }; + + // Write report to file + await writeFile( + 'bundle-size-report.json', + JSON.stringify(report, null, 2) + ); + + // Print summary + console.log('\n=== Bundle Size Report ===\n'); + console.log('First Paint (Progress Bar Visible):'); + console.log( + ` Total: ${(measurements.firstPaint.totalBytes / 1024 / 1024).toFixed(2)} MB` + ); + console.log(` Files: ${measurements.firstPaint.fileCount}`); + console.log(` Time: ${measurements.firstPaint.timestamp}ms`); + + console.log('\nWordPress Loaded (Site Ready):'); + console.log( + ` Total: ${(measurements.wordpressLoaded.totalBytes / 1024 / 1024).toFixed(2)} MB` + ); + console.log(` Files: ${measurements.wordpressLoaded.fileCount}`); + console.log(` Time: ${measurements.wordpressLoaded.timestamp}ms`); + + console.log('\nOffline Mode Ready (All Downloads Settled):'); + console.log( + ` Total: ${(measurements.offlineModeReady.totalBytes / 1024 / 1024).toFixed(2)} MB` + ); + console.log(` Files: ${measurements.offlineModeReady.fileCount}`); + console.log(` Time: ${measurements.offlineModeReady.timestamp}ms`); + + console.log('\nReport written to: bundle-size-report.json'); + } catch (error) { + console.error('Error during measurement:', error); + throw error; + } finally { + await browser.close(); + } + + return measurements; +} + +// Run the measurement +measureBundleSize().catch((error) => { + console.error('Failed to measure bundle size:', error); + process.exit(1); +}); From e9e334d2248239423374e6775b082c94b4b81841 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 26 Oct 2025 07:40:47 +0000 Subject: [PATCH 6/6] Fix selector and use WordPress admin bar for ready detection Co-authored-by: adamziel <205419+adamziel@users.noreply.github.com> --- tools/scripts/README.md | 6 +++--- tools/scripts/measure-bundle-size-browser.mjs | 14 ++++++++------ 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/tools/scripts/README.md b/tools/scripts/README.md index 7be3c05294..8231c3d12c 100644 --- a/tools/scripts/README.md +++ b/tools/scripts/README.md @@ -121,11 +121,11 @@ Measures all downloads until the progress bar becomes visible. This represents t ### WordPress Loaded (Site Ready) -Measures all downloads until WordPress is fully loaded in the nested iframe, indicating the site is interactive and ready to use. +Measures all downloads until WordPress is fully loaded in the nested iframe, indicated by the WordPress admin bar being visible. **Key signals:** -- WordPress iframe body element is attached -- Falls back to window load event if iframe not found +- WordPress admin bar (`#wpadminbar`) is visible in the nested iframe +- Falls back to window load event if admin bar not found ### Offline Mode Ready (All Downloads Settled) diff --git a/tools/scripts/measure-bundle-size-browser.mjs b/tools/scripts/measure-bundle-size-browser.mjs index 36a8ee551d..cf020cea84 100755 --- a/tools/scripts/measure-bundle-size-browser.mjs +++ b/tools/scripts/measure-bundle-size-browser.mjs @@ -228,13 +228,15 @@ async function measureBundleSize() { }; } - // Stage 2: Wait for WordPress to load (nested iframes ready) + // Stage 2: Wait for WordPress to load (admin bar visible) console.log('Waiting for WordPress to load...'); try { - // Wait for the WordPress iframe - const wpFrame = page.frameLocator('#playground-viewport:visible, .playground-viewport:visible').frameLocator('#wp'); - await wpFrame.locator('body').waitFor({ - state: 'attached', + // Wait for the WordPress iframe (remote iframe -> WordPress iframe) + const wpFrame = page.frameLocator('#playground-viewport:visible').frameLocator('#wp'); + + // Wait for WordPress admin bar to be visible + await wpFrame.locator('#wpadminbar').waitFor({ + state: 'visible', timeout: 30000, }); @@ -253,7 +255,7 @@ async function measureBundleSize() { `WordPress loaded at ${wpLoadedTime}ms, ${measurements.wordpressLoaded.totalBytes} bytes downloaded` ); } catch (error) { - console.warn('WordPress iframe not found:', error.message); + console.warn('WordPress admin bar not found:', error.message); // Fall back to network load event await page.waitForLoadState('load'); const loadTime = Date.now() - monitor.startTime;