| 1 | import { readFileSync, readdirSync, statSync } from "node:fs"; |
| 2 | import { basename, resolve } from "node:path"; |
| 3 | import { gzipSync } from "node:zlib"; |
| 4 | |
| 5 | const distDir = resolve("dist"); |
| 6 | const indexPath = resolve(distDir, "index.html"); |
| 7 | const html = readFileSync(indexPath, "utf8"); |
| 8 | |
| 9 | function gzipBytes(path) { |
| 10 | return gzipSync(readFileSync(path), { level: 9 }).byteLength; |
| 11 | } |
| 12 | |
| 13 | function initialAssetPaths(extension) { |
| 14 | const pattern = extension === ".js" |
| 15 | ? /<(?:script|link)\b[^>]+(?:src|href)=["']([^"']+\.js)["'][^>]*>/g |
| 16 | : /<link\b[^>]+href=["']([^"']+\.css)["'][^>]*>/g; |
| 17 | return [...new Set([...html.matchAll(pattern)].map((match) => resolve(distDir, match[1])))]; |
| 18 | } |
| 19 | |
| 20 | function formatKiB(bytes) { |
| 21 | return `${(bytes / 1024).toFixed(1)} KiB`; |
| 22 | } |
| 23 | |
| 24 | function assertBudget(label, actual, budget) { |
| 25 | if (actual > budget) { |
| 26 | throw new Error(`${label} is ${formatKiB(actual)}; budget is ${formatKiB(budget)}`); |
| 27 | } |
| 28 | process.stdout.write(` PASS ${label}: ${formatKiB(actual)} / ${formatKiB(budget)}\n`); |
| 29 | } |
| 30 | |
| 31 | const initialJS = initialAssetPaths(".js"); |
| 32 | const initialCSS = initialAssetPaths(".css"); |
| 33 | if (!initialJS.length) throw new Error("no initial JavaScript assets found in dist/index.html"); |
| 34 | if (!initialCSS.length) throw new Error("no initial CSS assets found in dist/index.html"); |
| 35 | |
| 36 | const initialJSGzip = initialJS.reduce((total, path) => total + gzipBytes(path), 0); |
| 37 | const initialCSSGzip = initialCSS.reduce((total, path) => total + gzipBytes(path), 0); |
| 38 | const largestInitialJS = Math.max(...initialJS.map(gzipBytes)); |
| 39 | const largestInitialJSRaw = Math.max(...initialJS.map((path) => statSync(path).size)); |
| 40 | const localeChunks = readdirSync(resolve(distDir, "assets")) |
| 41 | .filter((name) => /^(?:zh|zh-TW)-.+\.js$/.test(name)) |
| 42 | .map((name) => resolve(distDir, "assets", name)); |
| 43 | |
| 44 | console.log("\nbundle budgets"); |
| 45 | assertBudget("initial JavaScript gzip", initialJSGzip, 430 * 1024); |
| 46 | assertBudget("largest initial JavaScript chunk gzip", largestInitialJS, 295 * 1024); |
| 47 | // Extension surfaces, Task Monitor, and compact decision receipts share the |
| 48 | // always-loaded shell. Keep their combined allowance bounded to 113 KiB gzip. |
| 49 | assertBudget("initial CSS gzip", initialCSSGzip, 113 * 1024); |
| 50 | if (localeChunks.length !== 2) { |
| 51 | throw new Error(`expected 2 on-demand Chinese locale chunks, found ${localeChunks.length}`); |
| 52 | } |
| 53 | for (const path of localeChunks) { |
| 54 | const name = basename(path); |
| 55 | // Task Monitor adds 37 user-facing labels to each on-demand locale, while |
| 56 | // Extension UI adds its own status and action copy. Shell execution contract |
| 57 | // cards add a small set of verification/risk strings. Keep both dictionaries |
| 58 | // within narrowly measured, explicit allowances. |
| 59 | const budget = name.startsWith("zh-TW-") ? 53.5 * 1024 : 52.75 * 1024; |
| 60 | assertBudget(`${name} gzip`, gzipBytes(path), budget); |
| 61 | } |
| 62 | |
| 63 | const rawInitialBytes = [...initialJS, ...initialCSS].reduce((total, path) => total + statSync(path).size, 0); |
| 64 | // Extension UI adds always-loaded cards, status chips, and palette styles; |
| 65 | // Task Monitor adds a small always-loaded style surface while its panel code |
| 66 | // remains lazy-loaded. The gzip budgets above remain the user-visible transfer |
| 67 | // constraint, with a narrowly measured raw allowance for both surfaces. |
| 68 | assertBudget("initial raw JavaScript and CSS", rawInitialBytes, 2_270 * 1024); |
| 69 | assertBudget("largest initial JavaScript chunk raw", largestInitialJSRaw, 1_100 * 1024); |
| 70 |