|
| 1 | +// Temporary performance profiling spec — not meant for CI assertions. |
| 2 | +// Run with: npx playwright test e2e/perf-profile.spec.js --reporter=list |
| 3 | + |
| 4 | +import { test } from '@playwright/test'; |
| 5 | + |
| 6 | +const BASE_URL = 'http://127.0.0.1:4173'; |
| 7 | + |
| 8 | +test('Core Web Vitals on /lesson/sv/welcome', async ({ page }) => { |
| 9 | + // Inject PerformanceObserver listeners before navigation so we catch all entries. |
| 10 | + await page.addInitScript(() => { |
| 11 | + window.__cwv = { lcp: null, fcp: null, cls: 0, inp: null }; |
| 12 | + |
| 13 | + // LCP |
| 14 | + try { |
| 15 | + new PerformanceObserver((list) => { |
| 16 | + const entries = list.getEntries(); |
| 17 | + if (entries.length) { |
| 18 | + window.__cwv.lcp = entries[entries.length - 1].startTime; |
| 19 | + } |
| 20 | + }).observe({ type: 'largest-contentful-paint', buffered: true }); |
| 21 | + } catch (e) {} |
| 22 | + |
| 23 | + // FCP |
| 24 | + try { |
| 25 | + new PerformanceObserver((list) => { |
| 26 | + for (const entry of list.getEntries()) { |
| 27 | + if (entry.name === 'first-contentful-paint') { |
| 28 | + window.__cwv.fcp = entry.startTime; |
| 29 | + } |
| 30 | + } |
| 31 | + }).observe({ type: 'paint', buffered: true }); |
| 32 | + } catch (e) {} |
| 33 | + |
| 34 | + // CLS |
| 35 | + try { |
| 36 | + new PerformanceObserver((list) => { |
| 37 | + for (const entry of list.getEntries()) { |
| 38 | + if (!entry.hadRecentInput) { |
| 39 | + window.__cwv.cls += entry.value; |
| 40 | + } |
| 41 | + } |
| 42 | + }).observe({ type: 'layout-shift', buffered: true }); |
| 43 | + } catch (e) {} |
| 44 | + |
| 45 | + // INP (Interaction to Next Paint) — only available in some browsers |
| 46 | + try { |
| 47 | + new PerformanceObserver((list) => { |
| 48 | + for (const entry of list.getEntries()) { |
| 49 | + if (window.__cwv.inp === null || entry.duration > window.__cwv.inp) { |
| 50 | + window.__cwv.inp = entry.duration; |
| 51 | + } |
| 52 | + } |
| 53 | + }).observe({ type: 'event', buffered: true, durationThreshold: 16 }); |
| 54 | + } catch (e) {} |
| 55 | + }); |
| 56 | + |
| 57 | + const navStart = Date.now(); |
| 58 | + await page.goto(`${BASE_URL}/lesson/sv/welcome`, { waitUntil: 'networkidle' }); |
| 59 | + const navEnd = Date.now(); |
| 60 | + |
| 61 | + // Give observers a moment to flush |
| 62 | + await page.waitForTimeout(1000); |
| 63 | + |
| 64 | + const cwv = await page.evaluate(() => window.__cwv); |
| 65 | + |
| 66 | + // TTFB from Navigation Timing API |
| 67 | + const ttfb = await page.evaluate(() => { |
| 68 | + const nav = performance.getEntriesByType('navigation')[0]; |
| 69 | + return nav ? nav.responseStart - nav.requestStart : null; |
| 70 | + }); |
| 71 | + |
| 72 | + console.log('\n========== CORE WEB VITALS =========='); |
| 73 | + console.log(`TTFB: ${ttfb !== null ? ttfb.toFixed(1) + ' ms' : 'n/a'}`); |
| 74 | + console.log(`FCP: ${cwv.fcp !== null ? cwv.fcp.toFixed(1) + ' ms' : 'n/a'}`); |
| 75 | + console.log(`LCP: ${cwv.lcp !== null ? cwv.lcp.toFixed(1) + ' ms' : 'n/a'}`); |
| 76 | + console.log(`CLS: ${cwv.cls.toFixed(4)}`); |
| 77 | + console.log(`INP: ${cwv.inp !== null ? cwv.inp.toFixed(1) + ' ms' : 'n/a (no interactions)'}`); |
| 78 | + console.log(`Wall-clock page load: ${navEnd - navStart} ms`); |
| 79 | + console.log('=====================================\n'); |
| 80 | +}); |
| 81 | + |
| 82 | +test('JS resource load times on /lesson/sv/welcome', async ({ page }) => { |
| 83 | + await page.goto(`${BASE_URL}/lesson/sv/welcome`, { waitUntil: 'networkidle' }); |
| 84 | + await page.waitForTimeout(500); |
| 85 | + |
| 86 | + const resources = await page.evaluate(() => { |
| 87 | + return performance.getEntriesByType('resource') |
| 88 | + .filter(r => r.initiatorType === 'script' || r.name.endsWith('.js') || r.name.endsWith('.wasm')) |
| 89 | + .map(r => ({ |
| 90 | + name: r.name.replace(/.*\//, '').slice(0, 60), |
| 91 | + duration: r.duration, |
| 92 | + transferSize: r.transferSize, |
| 93 | + encodedBodySize: r.encodedBodySize, |
| 94 | + decodedBodySize: r.decodedBodySize, |
| 95 | + initiatorType: r.initiatorType, |
| 96 | + })) |
| 97 | + .sort((a, b) => b.duration - a.duration); |
| 98 | + }); |
| 99 | + |
| 100 | + const totalTransfer = await page.evaluate(() => { |
| 101 | + return performance.getEntriesByType('resource') |
| 102 | + .reduce((sum, r) => sum + (r.transferSize || 0), 0); |
| 103 | + }); |
| 104 | + |
| 105 | + const totalDecoded = await page.evaluate(() => { |
| 106 | + return performance.getEntriesByType('resource') |
| 107 | + .reduce((sum, r) => sum + (r.decodedBodySize || 0), 0); |
| 108 | + }); |
| 109 | + |
| 110 | + console.log('\n========== JS/WASM RESOURCE LOAD TIMES (slowest first) =========='); |
| 111 | + for (const r of resources.slice(0, 20)) { |
| 112 | + const kb = r.transferSize ? (r.transferSize / 1024).toFixed(1) + ' KB' : '(cached/0)'; |
| 113 | + console.log(` ${r.duration.toFixed(0).padStart(6)} ms ${kb.padStart(12)} ${r.name}`); |
| 114 | + } |
| 115 | + console.log(`\nTotal JS/WASM resources shown: ${resources.length}`); |
| 116 | + console.log(`Total transferred (all resources): ${(totalTransfer / 1024).toFixed(1)} KB`); |
| 117 | + console.log(`Total decoded (all resources): ${(totalDecoded / 1024).toFixed(1)} KB`); |
| 118 | + console.log('==================================================================\n'); |
| 119 | +}); |
| 120 | + |
| 121 | +test('SPA navigation timing: welcome → modules-and-ports → always-comb', async ({ page }) => { |
| 122 | + // The sidebar uses button[data-active] for lesson buttons, not <a> links. |
| 123 | + // Chapter buttons have no data-active attribute. |
| 124 | + await page.goto(`${BASE_URL}/lesson/sv/welcome`, { waitUntil: 'networkidle' }); |
| 125 | + |
| 126 | + // Wait for sidebar to initialise |
| 127 | + await page.locator('button[data-active]').first().waitFor({ timeout: 10000 }); |
| 128 | + |
| 129 | + // --- Navigate to modules-and-ports --- |
| 130 | + // "Modules and Ports" is in the "Basics" chapter. |
| 131 | + // First ensure the lesson button is visible; if not, open the chapter. |
| 132 | + const modulesBtn = page.locator('button[data-active]').filter({ hasText: 'Modules and Ports' }); |
| 133 | + if ((await modulesBtn.count()) === 0) { |
| 134 | + await page.locator('button:not([data-active])').filter({ hasText: 'Basics' }).click(); |
| 135 | + } |
| 136 | + |
| 137 | + const t0 = Date.now(); |
| 138 | + await modulesBtn.first().click({ timeout: 10000 }); |
| 139 | + // Wait for lesson content heading to appear |
| 140 | + await page.locator('button[data-active]').filter({ hasText: 'Modules and Ports' }).waitFor({ timeout: 10000 }); |
| 141 | + const t1 = Date.now(); |
| 142 | + const nav1 = t1 - t0; |
| 143 | + |
| 144 | + await page.waitForTimeout(300); |
| 145 | + |
| 146 | + // --- Navigate to always-comb --- |
| 147 | + // "always_comb and case" lesson name |
| 148 | + const alwaysBtn = page.locator('button[data-active]').filter({ hasText: 'always_comb' }); |
| 149 | + if ((await alwaysBtn.count()) === 0) { |
| 150 | + // It may be in a chapter that's not yet open — try "Combinational Logic" chapter |
| 151 | + await page.locator('button:not([data-active])').filter({ hasText: 'Combinational Logic' }).first().click(); |
| 152 | + } |
| 153 | + |
| 154 | + const t2 = Date.now(); |
| 155 | + await alwaysBtn.first().click({ timeout: 10000 }); |
| 156 | + await page.locator('button[data-active]').filter({ hasText: 'always_comb' }).waitFor({ timeout: 10000 }); |
| 157 | + const t3 = Date.now(); |
| 158 | + const nav2 = t3 - t2; |
| 159 | + |
| 160 | + console.log('\n========== SPA NAVIGATION TIMING =========='); |
| 161 | + console.log(`welcome → modules-and-ports: ${nav1} ms`); |
| 162 | + console.log(`modules-and-ports → always-comb: ${nav2} ms`); |
| 163 | + console.log(`Average SPA nav time: ${((nav1 + nav2) / 2).toFixed(0)} ms`); |
| 164 | + console.log('===========================================\n'); |
| 165 | +}); |
| 166 | + |
| 167 | +test('Total JS transferred summary', async ({ page }) => { |
| 168 | + await page.goto(`${BASE_URL}/lesson/sv/welcome`, { waitUntil: 'networkidle' }); |
| 169 | + await page.waitForTimeout(500); |
| 170 | + |
| 171 | + const summary = await page.evaluate(() => { |
| 172 | + const entries = performance.getEntriesByType('resource'); |
| 173 | + const byType = {}; |
| 174 | + let totalTransfer = 0; |
| 175 | + let totalDecoded = 0; |
| 176 | + for (const r of entries) { |
| 177 | + const type = r.initiatorType || 'other'; |
| 178 | + if (!byType[type]) byType[type] = { count: 0, transfer: 0, decoded: 0 }; |
| 179 | + byType[type].count++; |
| 180 | + byType[type].transfer += r.transferSize || 0; |
| 181 | + byType[type].decoded += r.decodedBodySize || 0; |
| 182 | + totalTransfer += r.transferSize || 0; |
| 183 | + totalDecoded += r.decodedBodySize || 0; |
| 184 | + } |
| 185 | + return { byType, totalTransfer, totalDecoded, count: entries.length }; |
| 186 | + }); |
| 187 | + |
| 188 | + console.log('\n========== TOTAL JS TRANSFERRED =========='); |
| 189 | + console.log(`Total resources: ${summary.count}`); |
| 190 | + console.log(`Total transferred: ${(summary.totalTransfer / 1024).toFixed(1)} KB`); |
| 191 | + console.log(`Total decoded: ${(summary.totalDecoded / 1024).toFixed(1)} KB`); |
| 192 | + console.log('\nBy initiator type:'); |
| 193 | + for (const [type, data] of Object.entries(summary.byType).sort((a, b) => b[1].transfer - a[1].transfer)) { |
| 194 | + console.log(` ${type.padEnd(12)} count=${data.count} transfer=${(data.transfer/1024).toFixed(1)} KB decoded=${(data.decoded/1024).toFixed(1)} KB`); |
| 195 | + } |
| 196 | + console.log('==========================================\n'); |
| 197 | +}); |
0 commit comments