Skill: wu-uk/fix-visual-stability-browser-testing (version 0.1.0)

--- name: browser-testing description: "VERIFY your changes work. Measure CLS, detect theme flicker, test visual stability, check performance. Use BEFORE and AFTER making changes to confirm fixes. Includes ready-to-run scripts: measure-cls.ts, detect-flicker.ts" --- # Performance Measurement with Playwright CDP Diagnose performance issues by measuring actual load times and network activity. **Playwright is pre-installed.** Just use the measurement script. ## Quick Start A `measure.ts` script is included in this skill's directory. Find it and run: ```bash # Measure a page (outputs JSON with waterfall data) npx ts-node <path-to-this-skill>/measure.ts http://localhost:3000 # Measure an API endpoint npx ts-node <path-to-this-skill>/measure.ts http://localhost:3000/api/products ``` The script is in the same directory as this SKILL.md file. ## Understanding the Output The script outputs JSON with: ```json { "url": "http://localhost:3000", "totalMs": 1523, "requests": [ { "url": "http://localhost:3000/", "ms": 45.2 }, { "url": "http://localhost:3000/api/products", "ms": 512.3 }, { "url": "http://localhost:3000/api/featured", "ms": 301.1 } ], "metrics": { "JSHeapUsedSize": 4521984, "LayoutCount": 12, "ScriptDuration": 0.234 } } ``` ### Reading the Waterfall The `requests` array shows network timing. Look for **sequential patterns**: ``` BAD (sequential - each waits for previous): /api/products |████████| 512ms /api/featured |██████| 301ms (starts AFTER products) /api/categories |████| 201ms (starts AFTER featured) Total: 1014ms GOOD (parallel - all start together): /api/products |████████| 512ms /api/featured |██████| 301ms (starts SAME TIME) /api/categories |████| 201ms (starts SAME TIME) Total: 512ms (just the slowest one) ``` ### Key Metrics | Metric | What it means | Red flag | |--------|---------------|----------| | `totalMs` | Total page load time | > 1000ms | | `JSHeapUsedSize` | Memory used by JS | Growing over time | | `LayoutCount` | Layout recalculations | > 50 per page | | `ScriptDuration` | Time in JS execution | > 0.5s | ## What to Look For | Symptom | Likely Cause | Fix | |---------|--------------|-----| | Requests in sequence | Sequential `await` statements | Use `Promise.all()` | | Same URL requested twice | Fetch before cache check | Check cache first | | Long time before response starts | Blocking operation before sending | Make it async/non-blocking | | High LayoutCount | Components re-rendering | Add `React.memo`, `useMemo` | ## Measuring API Endpoints Directly For quick API timing without browser overhead: ```typescript async function measureAPI(url: string) { const start = Date.now(); const response = await fetch(url); const elapsed = Date.now() - start; return { url, time_ms: elapsed, status: response.status }; } // Example const endpoints = [ 'http://localhost:3000/api/products', 'http://localhost:3000/api/products?cache=false', 'http://localhost:3000/api/checkout', ]; for (const endpoint of endpoints) { const result = await measureAPI(endpoint); console.log(`${endpoint}: ${result.time_ms}ms`); } ``` ## How the Measurement Script Works The script uses Chrome DevTools Protocol (CDP) to intercept browser internals: 1. **Network.requestWillBeSent** - Event fired when request starts, we record timestamp 2. **Network.responseReceived** - Event fired when response arrives, we calculate duration 3. **Performance.getMetrics** - Returns Chrome's internal counters (memory, layout, script time) This gives you the same data as Chrome DevTools Network tab, but programmatically. ## Visual Stability Measurement ### Measure CLS (Cumulative Layout Shift) ```bash npx ts-node <path-to-this-skill>/measure-cls.ts http://localhost:3000 ``` Output: ```json { "url": "http://localhost:3000", "cls": 0.42, "rating": "poor", "shifts": [ { "value": 0.15, "hadRecentInput": false, "sources": [ {"nodeId": 42, "previousRect": {...}, "currentRect": {...}} ] } ] } ``` ### CLS Thresholds | CLS Score | Rating | Action | |-----------|--------|--------| | < 0.1 | Good | No action needed | | 0.1 - 0.25 | Needs Improvement | Review shift sources | | > 0.25 | Poor | Fix immediately | ### Detect Theme Flicker ```bash npx ts-node <path-to-this-skill>/detect-flicker.ts http://localhost:3000 ``` Detects if dark theme flashes white before loading. Sets localStorage theme before navigation and checks background color at first paint. ### Accurate CLS Measurement CLS only measures shifts **within the viewport**. Content that loads below the fold doesn't contribute until you scroll. For accurate measurement: **Recommended testing sequence:** 1. Load page 2. Wait 3 seconds (let late-loading content appear) 3. Scroll to bottom 4. Wait 2 seconds 5. Trigger 1-2 UI actions (theme toggle, filter click, etc.) 6. Wait 2 seconds 7. Read final CLS ```bash # Basic measurement (may miss shifts from late content) npx ts-node <path-to-this-skill>/measure-cls.ts http://localhost:3000 # With scrolling (catches more shifts) npx ts-node <path-to-this-skill>/measure-cls.ts http://localhost:3000 --scroll ``` **Why measurements vary:** - Production vs development builds have different timing - Viewport size affects what's "in view" during shifts - setTimeout delays vary slightly between runs - Network conditions affect when content loads The relative difference (before/after fix) matters more than absolute values. ### Common CLS Causes | Shift Source | Likely Cause | Fix | |--------------|--------------|-----| | `<img>` elements | Missing width/height | Add dimensions or use `next/image` | | Theme wrapper | Hydration flicker | Use inline script before React | | Skeleton loaders | Size mismatch | Match skeleton to final content size | | Dynamic banners | No reserved space | Add `min-height` to container | | Late-loading sidebars | Content appears and pushes main content | Reserve space with CSS or show placeholder | | Pagination/results bars | UI element appears after data loads | Show immediately with loading state | | Font loading | Custom fonts cause text reflow | Use `font-display: swap` or preload fonts |

path: detect-flicker.ts
content: import { chromium } from 'playwright';
import { PNG } from 'pngjs';

interface FlickerResult {
  url: string;
  expectedTheme: string;
  flickerDetected: boolean;
  earlyScreenshot: string;
  finalScreenshot: string;
  earlyBrightness: number;
  finalBrightness: number;
  diagnosis: string;
}

function getAverageBrightness(pngBuffer: Buffer): number {
  const png = PNG.sync.read(pngBuffer);
  let totalBrightness = 0;
  const pixelCount = png.width * png.height;

  for (let y = 0; y < png.height; y++) {
    for (let x = 0; x < png.width; x++) {
      const idx = (png.width * y + x) << 2;
      const r = png.data[idx];
      const g = png.data[idx + 1];
      const b = png.data[idx + 2];
      // Perceived brightness formula
      totalBrightness += (r * 0.299 + g * 0.587 + b * 0.114);
    }
  }

  return totalBrightness / pixelCount;
}

function isLightImage(brightness: number): boolean {
  return brightness > 150;
}

async function detectFlicker(url: string, expectedTheme: string = 'dark'): Promise<FlickerResult> {
  const browser = await chromium.launch();
  const context = await browser.newContext();

  // Set theme preference BEFORE page loads
  await context.addInitScript(`localStorage.setItem('theme', '${expectedTheme}');`);

  const page = await context.newPage();

  // Navigate with "commit" to catch earliest paint
  await page.goto(url, { waitUntil: 'commit' });
  await page.waitForTimeout(50);

  // Take early screenshot and save
  const earlyPath = '/tmp/flicker-early.png';
  const earlyScreenshot = await page.screenshot({ path: earlyPath });

  // Wait for full load
  await page.waitForLoadState('networkidle');
  await page.waitForTimeout(500);

  // Take final screenshot and save
  const finalPath = '/tmp/flicker-final.png';
  const finalScreenshot = await page.screenshot({ path: finalPath });

  await browser.close();

  // Analyze screenshots
  const earlyBrightness = getAverageBrightness(earlyScreenshot);
  const finalBrightness = getAverageBrightness(finalScreenshot);

  const earlyIsLight = isLightImage(earlyBrightness);
  const finalIsLight = isLightImage(finalBrightness);

  // Flicker detected if:
  // 1. Early and final have different brightness (theme changed during load)
  // 2. OR early doesn't match expected theme
  const themeChanged = earlyIsLight !== finalIsLight;
  const earlyMatchesExpected = expectedTheme === 'dark' ? !earlyIsLight : earlyIsLight;

  let diagnosis: string;
  if (themeChanged) {
    diagnosis = `Theme flicker! Page went from ${earlyIsLight ? 'light' : 'dark'} to ${finalIsLight ? 'light' : 'dark'} during load`;
  } else if (!earlyMatchesExpected) {
    diagnosis = `Wrong initial theme: expected ${expectedTheme} but got ${earlyIsLight ? 'light' : 'dark'} at first paint`;
  } else {
    diagnosis = 'No flicker - correct theme from first paint';
  }

  return {
    url,
    expectedTheme,
    flickerDetected: themeChanged || !earlyMatchesExpected,
    earlyScreenshot: earlyPath,
    finalScreenshot: finalPath,
    earlyBrightness: Math.round(earlyBrightness),
    finalBrightness: Math.round(finalBrightness),
    diagnosis
  };
}

// Main
const url = process.argv[2] || 'http://localhost:3000';
const theme = process.argv[3] || 'dark';

detectFlicker(url, theme)
  .then(result => console.log(JSON.stringify(result, null, 2)))
  .catch(err => {
    console.error('Flicker detection failed:', err.message);
    process.exit(1);
  });


path: measure-cls.ts
content: import { chromium } from 'playwright';

interface CLSResult {
  url: string;
  cls: number;
  rating: 'good' | 'needs-improvement' | 'poor';
  metrics: Record<string, number>;
}

async function measureCLS(url: string, scroll: boolean = false): Promise<CLSResult> {
  const browser = await chromium.launch();
  const page = await browser.newPage();
  const client = await page.context().newCDPSession(page);

  // Enable Performance domain
  await client.send('Performance.enable');

  // Navigate and wait for load
  await page.goto(url, { waitUntil: 'networkidle' });

  // Optional: scroll to trigger lazy-loaded content shifts
  if (scroll) {
    await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
    await page.waitForTimeout(1000);
    await page.evaluate(() => window.scrollTo(0, 0));
    await page.waitForTimeout(500);
  }

  // Wait for any remaining shifts
  await page.waitForTimeout(2000);

  // Get all metrics including CumulativeLayoutShift
  const perfMetrics = await client.send('Performance.getMetrics');

  await browser.close();

  // Convert metrics array to object
  const metrics: Record<string, number> = {};
  for (const m of perfMetrics.metrics) {
    metrics[m.name] = m.value;
  }

  // CLS is directly available from CDP
  const cls = metrics['CumulativeLayoutShift'] || 0;

  // Determine rating (Google's thresholds)
  let rating: 'good' | 'needs-improvement' | 'poor';
  if (cls < 0.1) {
    rating = 'good';
  } else if (cls < 0.25) {
    rating = 'needs-improvement';
  } else {
    rating = 'poor';
  }

  return {
    url,
    cls: Math.round(cls * 1000) / 1000,
    rating,
    metrics
  };
}

// Main
const url = process.argv[2] || 'http://localhost:3000';
const scroll = process.argv.includes('--scroll');

measureCLS(url, scroll)
  .then(result => console.log(JSON.stringify(result, null, 2)))
  .catch(err => {
    console.error('CLS measurement failed:', err.message);
    process.exit(1);
  });


path: measure.ts
content: import { chromium } from 'playwright';

interface RequestTiming {
  url: string;
  start: number;
  end?: number;
}

interface MeasurementResult {
  url: string;
  totalMs: number;
  requests: { url: string; ms: number | null }[];
  metrics: Record<string, number>;
}

async function measure(url: string): Promise<MeasurementResult> {
  const browser = await chromium.launch();
  const page = await browser.newPage();

  // Create CDP session for low-level access
  const client = await page.context().newCDPSession(page);

  // Enable CDP domains we need
  await client.send('Performance.enable');
  await client.send('Network.enable');

  // Track network requests with timestamps
  const requests: RequestTiming[] = [];

  // Network.requestWillBeSent fires when browser is about to send a request
  // We record the URL and start timestamp to build the waterfall
  client.on('Network.requestWillBeSent', (event) => {
    requests.push({
      url: event.request.url,
      start: event.timestamp,
    });
  });

  // Network.responseReceived fires when response headers arrive
  // We find the matching request and record when it completed
  client.on('Network.responseReceived', (event) => {
    const req = requests.find(r => r.url === event.response.url && !r.end);
    if (req) {
      req.end = event.timestamp;
    }
  });

  // Measure total page load time
  const start = Date.now();
  await page.goto(url, { waitUntil: 'networkidle' });
  const totalMs = Date.now() - start;

  // Performance.getMetrics returns Chrome's internal performance counters:
  // - JSHeapUsedSize: Memory used by JavaScript objects
  // - LayoutCount: Number of layout recalculations (high = layout thrashing)
  // - RecalcStyleCount: Number of style recalculations
  // - ScriptDuration: Total time spent executing JavaScript
  // - TaskDuration: Total time spent on main thread tasks
  const perfMetrics = await client.send('Performance.getMetrics');

  await browser.close();

  // Convert timestamps to milliseconds duration
  const requestTimings = requests.map(r => ({
    url: r.url,
    ms: r.end ? (r.end - r.start) * 1000 : null,
  }));

  // Convert metrics array to object for easier reading
  const metrics: Record<string, number> = {};
  for (const m of perfMetrics.metrics) {
    metrics[m.name] = m.value;
  }

  return {
    url,
    totalMs,
    requests: requestTimings,
    metrics,
  };
}

// Main
const url = process.argv[2] || 'http://localhost:3000';
measure(url)
  .then(result => console.log(JSON.stringify(result, null, 2)))
  .catch(err => {
    console.error('Measurement failed:', err.message);
    process.exit(1);
  });


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (MEDIUM)